From 71d03df89003fa909eec571fd2c8ec48bd236619 Mon Sep 17 00:00:00 2001 From: Alireza Date: Sat, 25 May 2024 12:53:50 +0200 Subject: [PATCH] e2e tests, zero-state for a few views and a few bug fixes e2e tests are for example-app, but kept in the same cypress setup for simpler reuse of shared cypress configuration needed for both cases. --- .github/workflows/CI.yaml | 11 +- package.json | 3 +- packages/example-app/src/App.tsx | 6 +- .../src/Editor/EditorZeroState.tsx | 47 +++ .../example-app/src/Editor/FileEditor.tsx | 235 +++++++-------- .../src/Project/actions/createFileAction.tsx | 17 +- .../example-app/src/Project/fs-operations.ts | 6 +- .../example-app/src/Project/project.state.ts | 9 +- .../example-app/src/Project/toolWindows.tsx | 6 +- ...Initializer.tsx => ProjectInitializer.tsx} | 44 ++- .../src/ProjectView/ProjectView.state.ts | 4 +- .../src/ProjectView/ProjectViewPane.tsx | 1 + .../SearchEverywherePopup.tsx | 4 +- .../VersionControl/Branches/branches.state.ts | 16 +- .../Changes/ChangesView/ChangeViewTree.tsx | 1 + .../Changes/ChangesView/ChangesViewPane.tsx | 16 +- .../ChangesView/ChangesViewZeroState.tsx | 42 +++ .../Changes/change-lists.state.ts | 19 +- .../VersionControl/Changes/changes.state.ts | 44 ++- .../src/VersionControl/VcsActionIds.tsx | 1 + .../CommitChanges/CommitsChangedFiles.tsx | 1 + .../CommitsView/CommitsTable.tsx | 1 + .../VersionControlToolWindow.tsx | 11 +- .../VersionControlToolWindowZeroState.tsx | 43 +++ .../VersionControl/actions/gitInitAction.tsx | 33 +++ .../src/VersionControl/file-status.state.ts | 76 ++++- .../src/VersionControl/file-status.ts | 2 +- .../VersionControl/useShowGitTipIfNeeded.tsx | 2 +- .../src/VersionControl/useVcsActions.tsx | 8 +- packages/example-app/src/fs/fs.state.ts | 3 +- .../new/1.[component].cy-test.tsx.ejs.t | 2 +- packages/jui/cypress.config.ts | 20 ++ packages/jui/cypress/cypress.d.ts | 21 -- packages/jui/cypress/e2e/file-actions.cy.ts | 89 ++++++ .../cypress/e2e/vcs/branch-switching.cy.ts | 56 ++++ .../jui/cypress/e2e/vcs/checkin-action.cy.ts | 15 + packages/jui/cypress/e2e/vcs/commit.cy.ts | 38 +++ .../cypress/e2e/vcs/file-status-colors.cy.ts | 95 ++++++ .../cypress/e2e/vcs/nested-git-repos.cy.ts | 39 +++ .../jui/cypress/e2e/vcs/refresh-action.cy.ts | 14 + packages/jui/cypress/e2e/vcs/rollback.cy.ts | 126 ++++++++ .../components-in-modal-window.cy.tsx | 2 +- .../integration}/mnemonic-and-modals.cy.tsx | 0 .../integration}/modal-window-and-tree.cy.tsx | 4 +- .../integration}/modal-window_menu.cy.tsx | 0 .../integration}/popup-and-menu.cy.tsx | 2 +- .../tool-windows-and-actions.cy.tsx | 2 +- .../tool-windows-and-tooltips.cy.tsx | 2 +- packages/jui/cypress/support/component.tsx | 30 +- packages/jui/cypress/support/e2e.ts | 24 ++ .../cypress/support/example-app/AppGlobals.ts | 6 + .../cypress/support/example-app/commands.ts | 101 +++++++ .../support/example-app/fileStatusColor.ts | 103 +++++++ .../jui/cypress/support/example-app/index.ts | 1 + .../support/example-app/initializers.ts | 272 ++++++++++++++++++ .../cypress/support/example-app/support.ts | 2 + .../support/{commands.ts => shared.ts} | 44 ++- packages/jui/package.json | 10 +- .../ActionSystem/components/ActionButton.tsx | 1 + .../jui/src/AlertDialog/AlertDialog.cy.tsx | 2 +- packages/jui/src/Balloon/Balloon.cy.tsx | 2 +- .../jui/src/Balloon/BalloonManager.cy.tsx | 2 +- packages/jui/src/Button/Button.cy.tsx | 2 +- .../jui/src/ButtonGroup/ButtonGroup.cy.tsx | 2 +- packages/jui/src/Checkbox/Checkbox.cy.tsx | 2 +- packages/jui/src/Icon/useSvgIcon.tsx | 17 +- packages/jui/src/InputField/Input.cy.tsx | 2 +- packages/jui/src/InputField/InputField.cy.tsx | 2 +- packages/jui/src/Link/Link.cy.tsx | 2 +- packages/jui/src/List/List.tsx | 3 +- packages/jui/src/List/ListItem.tsx | 1 + .../List/SpeedSearchList/SpeedSearchList.tsx | 3 +- packages/jui/src/Menu/Menu.cy.tsx | 2 +- packages/jui/src/Menu/SpeedSearchMenu.cy.tsx | 2 +- .../jui/src/Mnemonic/MnemonicTrigger.cy.tsx | 2 +- .../jui/src/ModalWindow/ModalWindow.cy.tsx | 2 +- packages/jui/src/Popup/Popup.cy.tsx | 2 +- .../jui/src/ProgressBar/ProgressBar.cy.tsx | 2 +- .../jui/src/SearchInput/SearchInput.cy.tsx | 2 +- packages/jui/src/StatusBar/StatusBar.cy.tsx | 2 +- packages/jui/src/Tabs/Tabs.cy.tsx | 4 +- packages/jui/src/Toolbar/Toolbar.cy.tsx | 2 +- packages/jui/src/Tooltip/Tooltip.cy.tsx | 2 +- .../SpeedSearchTree/SpeedSearchTree.cy.tsx | 2 +- .../Tree/SpeedSearchTree/SpeedSearchTree.tsx | 2 + packages/jui/src/Tree/Tree.cy.tsx | 2 +- packages/jui/src/Tree/Tree.tsx | 3 +- packages/jui/src/theme.cy.tsx | 2 +- packages/jui/tsconfig.cypress-e2e.json | 10 + packages/jui/tsconfig.cypress.json | 4 +- packages/jui/tsconfig.json | 3 + yarn.lock | 107 +++++++ 92 files changed, 1753 insertions(+), 281 deletions(-) create mode 100644 packages/example-app/src/Editor/EditorZeroState.tsx rename packages/example-app/src/{SampleRepoInitializer.tsx => ProjectInitializer.tsx} (71%) create mode 100644 packages/example-app/src/VersionControl/Changes/ChangesView/ChangesViewZeroState.tsx create mode 100644 packages/example-app/src/VersionControl/VersionControlToolWindow/VersionControlToolWindowZeroState.tsx create mode 100644 packages/example-app/src/VersionControl/actions/gitInitAction.tsx delete mode 100644 packages/jui/cypress/cypress.d.ts create mode 100644 packages/jui/cypress/e2e/file-actions.cy.ts create mode 100644 packages/jui/cypress/e2e/vcs/branch-switching.cy.ts create mode 100644 packages/jui/cypress/e2e/vcs/checkin-action.cy.ts create mode 100644 packages/jui/cypress/e2e/vcs/commit.cy.ts create mode 100644 packages/jui/cypress/e2e/vcs/file-status-colors.cy.ts create mode 100644 packages/jui/cypress/e2e/vcs/nested-git-repos.cy.ts create mode 100644 packages/jui/cypress/e2e/vcs/refresh-action.cy.ts create mode 100644 packages/jui/cypress/e2e/vcs/rollback.cy.ts rename packages/jui/{integration-tests => cypress/integration}/components-in-modal-window.cy.tsx (97%) rename packages/jui/{integration-tests => cypress/integration}/mnemonic-and-modals.cy.tsx (100%) rename packages/jui/{integration-tests => cypress/integration}/modal-window-and-tree.cy.tsx (89%) rename packages/jui/{integration-tests => cypress/integration}/modal-window_menu.cy.tsx (100%) rename packages/jui/{integration-tests => cypress/integration}/popup-and-menu.cy.tsx (92%) rename packages/jui/{integration-tests => cypress/integration}/tool-windows-and-actions.cy.tsx (98%) rename packages/jui/{integration-tests => cypress/integration}/tool-windows-and-tooltips.cy.tsx (96%) create mode 100644 packages/jui/cypress/support/e2e.ts create mode 100644 packages/jui/cypress/support/example-app/AppGlobals.ts create mode 100644 packages/jui/cypress/support/example-app/commands.ts create mode 100644 packages/jui/cypress/support/example-app/fileStatusColor.ts create mode 100644 packages/jui/cypress/support/example-app/index.ts create mode 100644 packages/jui/cypress/support/example-app/initializers.ts create mode 100644 packages/jui/cypress/support/example-app/support.ts rename packages/jui/cypress/support/{commands.ts => shared.ts} (76%) create mode 100644 packages/jui/tsconfig.cypress-e2e.json diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 8d23e377..7bfc0487 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -100,17 +100,20 @@ jobs: env: PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} # Without this, we would need to "finalize" percy build explicitly. More info: https://docs.percy.io/docs/parallel-test-suites - PERCY_PARALLEL_TOTAL: 10 + PERCY_PARALLEL_TOTAL: 20 # component and e2e, each 10 # Percy's nonce is supposed to be set to run id by default, but it's not. Plus, appending run_number makes it more reliable in case of rerunning PERCY_PARALLEL_NONCE: ${{ github.run_id }}-${{ github.run_number }} CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} - run: yarn run test:cypress --parallel - + run: | + yarn run test:cypress:component --record --parallel + yarn run test:cypress:e2e --record --parallel - name: Run Cypress Tests (without percy) ✅ if: ${{ !(env.IS_PR_JUST_MERGED == 'true' || env.IS_PR_WITH_PERCY == 'true') }} env: CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} - run: yarn run test:cypress --parallel + run: | + yarn run test:cypress:component --record --parallel + yarn run test:cypress:e2e --record --parallel - uses: actions/upload-artifact@v2 name: Upload Cypress screenshots if tests failed 🤕 diff --git a/package.json b/package.json index 1834edef..583c1e28 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "type-check": "yarn workspaces foreach run type-check", "test": "yarn workspaces foreach run test", - "test:cypress": "yarn workspaces foreach run test:cypress", + "test:cypress:component": "yarn workspaces foreach run cypress:component", + "test:cypress:e2e": "yarn workspaces foreach run cypress:e2e", "lint": "yarn run lint:lib", "lint:lib": "yarn eslint packages/jui", "format:check": "prettier packages/jui packages/website .github --check", diff --git a/packages/example-app/src/App.tsx b/packages/example-app/src/App.tsx index dbff9419..24a60be8 100644 --- a/packages/example-app/src/App.tsx +++ b/packages/example-app/src/App.tsx @@ -10,7 +10,7 @@ import { } from "@intellij-platform/core"; import { DefaultSuspense } from "./DefaultSuspense"; import { Project } from "./Project/Project"; -import { SampleRepoInitializer } from "./SampleRepoInitializer"; +import { ProjectInitializer } from "./ProjectInitializer"; import { fs, WaitForFs } from "./fs/fs"; import { exampleAppKeymap } from "./exampleAppKeymap"; import "./jetbrains-mono-font.css"; @@ -29,7 +29,7 @@ export const App = ({ height }: { height?: CSSProperties["height"] }) => { // TODO: add an error boundary - + @@ -39,7 +39,7 @@ export const App = ({ height }: { height?: CSSProperties["height"] }) => { - + ); diff --git a/packages/example-app/src/Editor/EditorZeroState.tsx b/packages/example-app/src/Editor/EditorZeroState.tsx new file mode 100644 index 00000000..24788241 --- /dev/null +++ b/packages/example-app/src/Editor/EditorZeroState.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { + CommonActionId, + styled, + useAction, + useGetActionShortcut, +} from "@intellij-platform/core"; + +const StyledContainer = styled.div.attrs({ tabIndex: -1 })` + display: flex; + flex-direction: column; + height: inherit; + justify-content: center; + padding: 0 25%; + font-size: 1rem; + outline: none; +`; + +const StyledLine = styled.div` + display: flex; + gap: 0.5rem; + margin-bottom: 0.5rem; +`; + +const StyledShortcut = styled.div` + color: ${({ theme }) => theme.commonColors.linkForegroundEnabled}; +`; + +export function EditorZeroState() { + return ( + + + + + ); +} + +function ActionTip({ actionId }: { actionId: string }) { + const getShortcut = useGetActionShortcut(); + const action = useAction(actionId); + return ( + + {action?.title} + {getShortcut(actionId)} + + ); +} diff --git a/packages/example-app/src/Editor/FileEditor.tsx b/packages/example-app/src/Editor/FileEditor.tsx index 60d9fb30..549ca8b2 100644 --- a/packages/example-app/src/Editor/FileEditor.tsx +++ b/packages/example-app/src/Editor/FileEditor.tsx @@ -36,6 +36,7 @@ import { mergeProps } from "@react-aria/utils"; import { useActivePathsProvider } from "../Project/project.state"; import { notImplemented } from "../Project/notImplemented"; import { useExistingLatestRecoilValue } from "../recoil-utils"; +import { EditorZeroState } from "./EditorZeroState"; const editorFullState = selector({ key: "editorState", @@ -60,7 +61,7 @@ export const FileEditor = () => { const hideAllAction = useAction(HIDE_ALL_WINDOWS_ACTION_ID); const setCursorPositionState = useSetRecoilState(editorCursorPositionState); - const [{ content, editorState, filePath }, loadingState] = + const [{ content, filePath }, loadingState] = useExistingLatestRecoilValue(editorFullState); // For functions that are needed in tab action callbacks. Because items are cached and referencing anything @@ -107,122 +108,128 @@ export const FileEditor = () => { }, })} > - {editorTabs.length > 0 && ( - ( - - Close - Close Other tabs - Close all tabs - Close tabs to the left - Close tabs to the right - - )} - > - { - editorStateManager.select( - editorTabs.findIndex((tab) => tab.filePath === key) - ); - }} - noBorders + {editorTabs.length > 0 ? ( + <> + ( + + Close + Close Other tabs + Close all tabs + Close tabs to the left + Close tabs to the right + + )} > - {(tab) => { - const filename = path.basename(tab.filePath); - const icon = ; - return ( - - } - > - } + { + editorStateManager.select( + editorTabs.findIndex((tab) => tab.filePath === key) + ); + }} + noBorders + > + {(tab) => { + const filename = path.basename(tab.filePath); + const icon = ( + + ); + return ( + + } > - - {filename} - - } - closeButton={ - - } - > - { - if (e.altKey) { - tabActionsRef.current.closeOthersTabs( - editorTabs.indexOf(tab) - ); - } else { - tabActionsRef.current.closePath(tab.filePath); - } - }} - /> - - } - containerProps={{ - onDoubleClick: () => { - hideAllAction?.perform(); - }, - }} - /> - - - ); - }} - - - )} - {typeof content === "string" ? ( - /** - * ## Note - * TLDR: Keeping the editor mounted when filePath changes is intentional and does matter. - * - * Whether the editor component is kept mounted as tabs change or not has nuances that can lead to minor - * focus issues. For example calling editorStateManager.focus() may do nothing if the editor is unmounted due - * to filePath being changed. focusing editor in createFile action is an example of such case. It also affects - * certain focus management code within the FileEditor. For example, focusing the editor on tab changes will - * be necessary if the editor remounts with each file change. Or autofocus behavior of the editor can be - * done on the onMount callback of the Editor component, if it's only mounted once. But the same code leads - * to focus issues if the editor is mounted on active tab changes, when they are not done via the tab UI, - * but as a side effect of another action like opening a file via Project tool window. - * - */ - { - monacoEditor.focus(); - enableJsx(monaco); - editorRef.current = monacoEditor; - monacoEditor.onDidChangeCursorPosition((e) => { - setCursorPositionState(e.position); - }); - monacoEditor.onDidChangeModel(() => { - // TODO: set the editor tab state, and add an atom effect to persist whole editor state across loads - }); - }} - onChange={updateContent} - value={content ?? ""} - /> + } + > + + {filename} + + } + closeButton={ + + } + > + { + if (e.altKey) { + tabActionsRef.current.closeOthersTabs( + editorTabs.indexOf(tab) + ); + } else { + tabActionsRef.current.closePath(tab.filePath); + } + }} + /> + + } + containerProps={{ + onDoubleClick: () => { + hideAllAction?.perform(); + }, + }} + /> + + + ); + }} + + + {typeof content === "string" ? ( + /** + * ## Note + * TLDR: Keeping the editor mounted when filePath changes is intentional and does matter. + * + * Whether the editor component is kept mounted as tabs change or not has nuances that can lead to minor + * focus issues. For example calling editorStateManager.focus() may do nothing if the editor is unmounted due + * to filePath being changed. focusing editor in createFile action is an example of such case. It also affects + * certain focus management code within the FileEditor. For example, focusing the editor on tab changes will + * be necessary if the editor remounts with each file change. Or autofocus behavior of the editor can be + * done on the onMount callback of the Editor component, if it's only mounted once. But the same code leads + * to focus issues if the editor is mounted on active tab changes, when they are not done via the tab UI, + * but as a side effect of another action like opening a file via Project tool window. + * + */ + { + monacoEditor.focus(); + enableJsx(monaco); + editorRef.current = monacoEditor; + monacoEditor.onDidChangeCursorPosition((e) => { + setCursorPositionState(e.position); + }); + monacoEditor.onDidChangeModel(() => { + // TODO: set the editor tab state, and add an atom effect to persist whole editor state across loads + }); + }} + onChange={updateContent} + value={content ?? ""} + /> + ) : ( + content && "UNSUPPORTED CONTENT" + )} + ) : ( - content && "UNSUPPORTED CONTENT" + )} {loadingState === "loading" && } diff --git a/packages/example-app/src/Project/actions/createFileAction.tsx b/packages/example-app/src/Project/actions/createFileAction.tsx index 4eda15ca..0dce3aa7 100644 --- a/packages/example-app/src/Project/actions/createFileAction.tsx +++ b/packages/example-app/src/Project/actions/createFileAction.tsx @@ -46,7 +46,7 @@ export const createFileActionState = selector({ .getLoadable(projectPopupManagerRefState) .getValue().current; - // TODO: open a dialog and let the user choose the destination + // TODO: open a dialog and let the user choose the destination if, multiple paths are active const destinationDir = ( await fs.promises.stat(activePaths[0]) ).isDirectory() @@ -104,6 +104,13 @@ function NewFileNamePopup({ // TODO: select it in the Project tool window, if it was created from the Project tool window close(); editorManager.focus(); + // Hacky approach to fix an edge case where the editor is not rendered already (no open tabs), and + // so will be focused with a little delay. For the focus to go back to the editor after AddFileToGitWindow + // is closed, without any extra focus management, we need to make sure the editor is focused before the + // modal window is opened, so it restores to the editor. + await waitUntil( + () => document.activeElement instanceof HTMLTextAreaElement + ); if (repoDir) { windowManager?.open(({ close }) => ( @@ -245,3 +252,11 @@ function AddFileToGitWindow({ ); } + +async function waitUntil(criteria: () => boolean, timeoutInMs = 1000) { + if (criteria() || timeoutInMs < 0) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + await waitUntil(criteria, timeoutInMs - 50); +} diff --git a/packages/example-app/src/Project/fs-operations.ts b/packages/example-app/src/Project/fs-operations.ts index 1d976b55..751ba9c7 100644 --- a/packages/example-app/src/Project/fs-operations.ts +++ b/packages/example-app/src/Project/fs-operations.ts @@ -26,10 +26,12 @@ export const deleteFileCallback = filepath: path.relative(repoDir, filepath), }); await refreshFileStatus(filepath); // TODO(fs.watch): better done separately using fs.watch - const editor = await snapshot.getPromise(editorManagerState); - editor.closePath(filepath); // TODO(fs.watch): better done as an effect on editorState, using fs.watch. But fs.watch is not available at the moment. } + } catch (e) { + console.error(`error in deleting file ${filepath}`, e); } finally { + const editor = await snapshot.getPromise(editorManagerState); + editor.closePath(filepath); // TODO(fs.watch): better done as an effect on editorState, using fs.watch. But fs.watch is not available at the moment. refresh(dirContentState(path.dirname(filepath))); // TODO(fs.watch): better done separately using fs.watch reset(fileContentState(filepath)); // TODO(fs.watch): better done separately using fs.watch } diff --git a/packages/example-app/src/Project/project.state.ts b/packages/example-app/src/Project/project.state.ts index 9eaa53f3..d9f1dd64 100644 --- a/packages/example-app/src/Project/project.state.ts +++ b/packages/example-app/src/Project/project.state.ts @@ -49,12 +49,13 @@ const sampleRepoKey: keyof typeof sampleRepos = "JUI"; */ export const sampleRepo = sampleRepos[sampleRepoKey]; +export const defaultProject = { + name: "Workspace", + path: "/workspace", +}; export const currentProjectState = atom({ key: "project", - default: { - name: "Workspace", - path: "/workspace", - }, + default: defaultProject, }); export const projectFilePath = selectorFamily({ diff --git a/packages/example-app/src/Project/toolWindows.tsx b/packages/example-app/src/Project/toolWindows.tsx index 63d1bede..dc66fce8 100644 --- a/packages/example-app/src/Project/toolWindows.tsx +++ b/packages/example-app/src/Project/toolWindows.tsx @@ -54,7 +54,11 @@ export const toolWindows: ToolWindowDescriptor[] = [ ), - initialState: toolWindowState({ anchor: "left", isVisible: false }), + initialState: toolWindowState({ + anchor: "left", + isSplit: true, // Default is false in the original impl. + isVisible: false, + }), }, { id: VERSION_CONTROL_TOOLWINDOW_ID, diff --git a/packages/example-app/src/SampleRepoInitializer.tsx b/packages/example-app/src/ProjectInitializer.tsx similarity index 71% rename from packages/example-app/src/SampleRepoInitializer.tsx rename to packages/example-app/src/ProjectInitializer.tsx index d59519b4..b508e28f 100644 --- a/packages/example-app/src/SampleRepoInitializer.tsx +++ b/packages/example-app/src/ProjectInitializer.tsx @@ -1,10 +1,12 @@ import React, { useEffect, useState } from "react"; -import { sampleRepo } from "./Project/project.state"; -import { fs } from "./fs/fs"; -import { clone } from "isomorphic-git"; +import path from "path"; +import git, { clone } from "isomorphic-git"; import http from "isomorphic-git/http/web"; import styled from "styled-components"; import { WINDOW_SHADOW } from "@intellij-platform/core"; +import { defaultProject, sampleRepo } from "./Project/project.state"; +import { fs } from "./fs/fs"; +import { ensureDir } from "./fs/fs-utils"; const StyledDialog = styled.div` position: absolute; @@ -49,27 +51,47 @@ export async function isSuccessfullyCloned(dir: string) { return true; } -export const SampleRepoInitializer: React.FC = ({ children }) => { +export const ProjectInitializer = ({ + children, + withSampleRepo = !location.search.includes("clone=false"), +}: { + withSampleRepo?: boolean; + children?: React.ReactNode; +}) => { const [state, setState] = useState< "error" | "cloning" | "uninitialized" | "initialized" >("uninitialized"); useEffect(() => { - async function init(dir: string, repoUrl: string) { + async function ensureSampleRepo(dir: string, repoUrl: string) { if (!(await isSuccessfullyCloned(dir))) { setState("cloning"); await cloneRepo({ dir, url: repoUrl }); } } - - init(sampleRepo.path, sampleRepo.url) - .then(() => { + // noinspection JSIgnoredPromiseFromCall: error handling done in the function + init(); + async function init() { + await ensureDir(fs.promises, defaultProject.path); + const externalInit = (window as any).INITIALIZE_APP; // Giving a chance for external fs initialization. Used in e2e tests + try { + if (externalInit) { + console.log("external initialization..."); + await externalInit({ + fs, + git, + path, + projectDir: defaultProject.path, + }); + } else if (withSampleRepo) { + await ensureSampleRepo(sampleRepo.path, sampleRepo.url); + } console.log("demo repo initialized"); setState("initialized"); - }) - .catch((e) => { + } catch (e) { console.error("could not initialize the demo repo", e); setState("error"); - }); + } + } }, []); if (state === "uninitialized") { diff --git a/packages/example-app/src/ProjectView/ProjectView.state.ts b/packages/example-app/src/ProjectView/ProjectView.state.ts index fdf3c4a8..9d0e4b45 100644 --- a/packages/example-app/src/ProjectView/ProjectView.state.ts +++ b/packages/example-app/src/ProjectView/ProjectView.state.ts @@ -48,7 +48,9 @@ export const expandedKeysState = atom({ key: "projectView.expandedKeys/default", get: ({ get }) => new Set( - get(vcsRootsState).flatMap(({ dir }) => getParentPaths(dir).concat(dir)) + get(vcsRootsState) + .flatMap(({ dir }) => getParentPaths(dir).concat(dir)) + .concat(get(currentProjectState).path) ), }), }); diff --git a/packages/example-app/src/ProjectView/ProjectViewPane.tsx b/packages/example-app/src/ProjectView/ProjectViewPane.tsx index 339d1ef3..5abfa3b4 100644 --- a/packages/example-app/src/ProjectView/ProjectViewPane.tsx +++ b/packages/example-app/src/ProjectView/ProjectViewPane.tsx @@ -59,6 +59,7 @@ export const ProjectViewPane = (): React.ReactElement => { {treeState?.root && ( ); } + const itemText = contributor.getItemText(item); return ( {contributor.renderItem(item)} diff --git a/packages/example-app/src/VersionControl/Branches/branches.state.ts b/packages/example-app/src/VersionControl/Branches/branches.state.ts index 7a1f2f35..b9e7837b 100644 --- a/packages/example-app/src/VersionControl/Branches/branches.state.ts +++ b/packages/example-app/src/VersionControl/Branches/branches.state.ts @@ -101,7 +101,15 @@ export const repoBranchesState = selectorFamily({ (repoRoot: string) => async ({ get }) => { const currentBranchName = get(repoCurrentBranchNameState(repoRoot)); - const localBranches = get(repoLocalBranchesState(repoRoot)); + let localBranches = get(repoLocalBranchesState(repoRoot)); + if (localBranches.length === 0 && currentBranchName) { + // When a repo is initialized and before the first commit, no branch is returned when listing branches. + // See more: https://github.com/isomorphic-git/isomorphic-git/issues/1650 + localBranches = localBranches.concat({ + name: currentBranchName, + trackingBranch: null, + }); + } return { repoRoot, currentBranch: @@ -149,6 +157,11 @@ export const branchForPathState = selectorFamily({ (filepath: string) => ({ get }) => { const root = get(vcsRootForFile(filepath)); + console.log( + `repo root for ${filepath}`, + root, + root && get(repoBranchesState(root)) + ); return root ? get(repoBranchesState(root)).currentBranch?.name || null : null; @@ -450,6 +463,7 @@ export function useCheckoutBranch() { const openedFiles = snapshot.getLoadable(editorTabsState).getValue(); const existingOpenedFile = await asyncFilter(async ({ filePath }) => { const exists = await fs.promises.exists(filePath); // should it be fs directly? + // the file content for files not open in the editor also need to be updated. TODO(fs.watch) if (exists) { await reloadFileFromDisk(filePath); } diff --git a/packages/example-app/src/VersionControl/Changes/ChangesView/ChangeViewTree.tsx b/packages/example-app/src/VersionControl/Changes/ChangesView/ChangeViewTree.tsx index d1e72186..326aff78 100644 --- a/packages/example-app/src/VersionControl/Changes/ChangesView/ChangeViewTree.tsx +++ b/packages/example-app/src/VersionControl/Changes/ChangesView/ChangeViewTree.tsx @@ -80,6 +80,7 @@ export const ChangeViewTree = ({ style={{ height: "100%" }} > ( - - - -); +export const ChangesViewPane = () => { + const vcsRoots = useRecoilValue(vcsRootsState); + return ( + + {vcsRoots.length > 0 ? : } + + ); +}; diff --git a/packages/example-app/src/VersionControl/Changes/ChangesView/ChangesViewZeroState.tsx b/packages/example-app/src/VersionControl/Changes/ChangesView/ChangesViewZeroState.tsx new file mode 100644 index 00000000..019db7ff --- /dev/null +++ b/packages/example-app/src/VersionControl/Changes/ChangesView/ChangesViewZeroState.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { + Link, + PlatformIcon, + styled, + usePerformAction, +} from "@intellij-platform/core"; +import { notImplemented } from "../../../Project/notImplemented"; +import { VcsActionIds } from "../../VcsActionIds"; + +const StyledContainer = styled.div` + min-height: 70%; + display: flex; + align-items: center; + justify-content: center; + padding: 50px; + color: ${({ theme }) => theme.commonColors.inactiveTextColor}; + white-space: nowrap; +`; +export function ChangesViewZeroState() { + const performAction = usePerformAction(); + return ( + +
+ To commit changes,{" "} + performAction(VcsActionIds.GIT_INIT)}> + Create Git repository... + +
+ For recent changes, see{" "} + Local History... +
+
+ {" "} + Version Control integration{" "} +
+
+ ); +} diff --git a/packages/example-app/src/VersionControl/Changes/change-lists.state.ts b/packages/example-app/src/VersionControl/Changes/change-lists.state.ts index 5b5622c3..30a3771d 100644 --- a/packages/example-app/src/VersionControl/Changes/change-lists.state.ts +++ b/packages/example-app/src/VersionControl/Changes/change-lists.state.ts @@ -88,12 +88,15 @@ export function SyncChangeListsState() { const setChangeLists = useSetRecoilState(changeListsState); useEffect(() => { setChangeLists((changeLists) => { + const trackedChanges = changes.filter( + (change) => !Change.isUnversioned(change) + ); const updatedChangeLists = changeLists.map((changeList) => { return { ...changeList, changes: changeList.changes .map((change) => - changes.find((aChange) => Change.equals(aChange, change)) + trackedChanges.find((aChange) => Change.equals(aChange, change)) ) .filter(notNull), }; @@ -101,14 +104,12 @@ export function SyncChangeListsState() { const existingChanges = updatedChangeLists.flatMap( ({ changes }) => changes ); - const newChanges = changes - .filter((change) => !Change.isUnversioned(change)) - .filter( - (change) => - !existingChanges.find((anExistingChange) => - Change.equals(change, anExistingChange) - ) - ); + const newChanges = trackedChanges.filter( + (change) => + !existingChanges.find((anExistingChange) => + Change.equals(change, anExistingChange) + ) + ); const activeChangeList = updatedChangeLists.find(({ active }) => active) ?? updatedChangeLists[0]; diff --git a/packages/example-app/src/VersionControl/Changes/changes.state.ts b/packages/example-app/src/VersionControl/Changes/changes.state.ts index 1a6856d3..418c08f5 100644 --- a/packages/example-app/src/VersionControl/Changes/changes.state.ts +++ b/packages/example-app/src/VersionControl/Changes/changes.state.ts @@ -1,18 +1,19 @@ import { selector, selectorFamily, useRecoilCallback } from "recoil"; import path from "path"; import { groupBy } from "ramda"; -import { checkout, resetIndex } from "isomorphic-git"; +import git, { checkout, resetIndex } from "isomorphic-git"; import { notNull } from "@intellij-platform/core/utils/array-utils"; import { dirContentState, reloadFileFromDiskCallback } from "../../fs/fs.state"; import { fs } from "../../fs/fs"; +import { findRootPaths } from "../../path-utils"; +import { editorManagerState } from "../../Editor/editor.state"; import { repoStatusState, useRefreshFileStatus, vcsRootForFile, vcsRootsState, } from "../file-status.state"; -import { findRootPaths } from "../../path-utils"; import { AnyChange, Change } from "./Change"; const repoChangesState = selectorFamily({ @@ -104,13 +105,36 @@ export const useRollbackChanges = () => { ) ); if (toCheckout.length > 0) { - await checkout({ - fs, - dir: repoRoot, - force: true, - filepaths: toCheckout.map( - ({ relativePath, change: { type } }) => relativePath - ), + const resolvedHead = await git + .resolveRef({ fs, dir: repoRoot, ref: "HEAD" }) + .catch(() => null); + if (resolvedHead) { + await checkout({ + fs, + dir: repoRoot, + force: true, + filepaths: toCheckout.map( + ({ relativePath, change: { type } }) => relativePath + ), + }); + } else { + // if the repository is just created, HEAD is not resolved, and checkout() throws, so we delete files + // manually in that case. + // TODO: another use case is detached HEAD, and it may need to be handled for non-addition changes? + await Promise.all( + toCheckout + .filter(({ change }) => change.type === "ADDED") + .map(({ change }) => { + const fullPath = Change.path(change); + return fs.promises.unlink(fullPath); + }) + ); + } + } + if (deleteAddedFiles) { + const editor = await snapshot.getPromise(editorManagerState); + changes.filter(Change.isAddition).forEach((change) => { + editor.closePath(Change.path(change)); // FIXME(fs.watch) }); } @@ -135,8 +159,8 @@ export const useRollbackChanges = () => { items.map(async ({ relativePath, change }) => { const fullPath = Change.path(change); await reloadFileFromDisk(fullPath); // Since fileContent is an atom, we set the value. Could be a selector that we would refresh - await updateFileStatus(fullPath); await resetIndex({ fs, dir: repoRoot, filepath: relativePath }); + await updateFileStatus(fullPath); }) ); }) diff --git a/packages/example-app/src/VersionControl/VcsActionIds.tsx b/packages/example-app/src/VersionControl/VcsActionIds.tsx index 23f5440b..fe22376c 100644 --- a/packages/example-app/src/VersionControl/VcsActionIds.tsx +++ b/packages/example-app/src/VersionControl/VcsActionIds.tsx @@ -20,6 +20,7 @@ export const VcsActionIds = { GROUP_CHANGES_VIEW_POPUP_MENU: "ChangesViewPopupMenu", // Not used yet GROUP_BY_DIRECTORY: "ChangesView.GroupBy.Directory", GIT_ADD: "Git.Add", + GIT_INIT: "Git.Init", GIT_CREATE_NEW_BRANCH: "Git.CreateNewBranch", GIT_BRANCHES: "Git.Branches", diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/CommitsChangedFiles.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/CommitsChangedFiles.tsx index 8678b679..11c4eb21 100644 --- a/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/CommitsChangedFiles.tsx +++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/CommitChanges/CommitsChangedFiles.tsx @@ -98,6 +98,7 @@ export function CommitChangedFiles({ )} {state && ( 0 && ( >({ export const VersionControlToolWindow = () => { const tabKeys = useRecoilValue(vcsTabKeysState); const [activeTabKey, setActiveTabKey] = useRecoilState(vcsActiveTabKeyState); - return ( + const repos = useRecoilValue(vcsRootsState); + + return repos.length > 0 ? ( Git:} activeKey={activeTabKey} @@ -83,6 +88,10 @@ export const VersionControlToolWindow = () => { ))} + ) : ( + + + ); }; diff --git a/packages/example-app/src/VersionControl/VersionControlToolWindow/VersionControlToolWindowZeroState.tsx b/packages/example-app/src/VersionControl/VersionControlToolWindow/VersionControlToolWindowZeroState.tsx new file mode 100644 index 00000000..54ed571a --- /dev/null +++ b/packages/example-app/src/VersionControl/VersionControlToolWindow/VersionControlToolWindowZeroState.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { + Link, + PlatformIcon, + styled, + usePerformAction, +} from "@intellij-platform/core"; +import { notImplemented } from "../../Project/notImplemented"; +import { VcsActionIds } from "../VcsActionIds"; + +const StyledContainer = styled.div` + min-height: 70%; + display: flex; + align-items: center; + justify-content: center; + padding: 50px; + color: ${({ theme }) => theme.commonColors.inactiveTextColor}; + white-space: nowrap; +`; +export function VersionControlToolWindowZeroState() { + const performAction = usePerformAction(); + + return ( + +
+ To track changes to code: +
+ performAction(VcsActionIds.GIT_INIT)}> + Create Git repository... + +
+ Use Local History... +
+
+ {" "} + Version Control integration{" "} +
+
+ ); +} diff --git a/packages/example-app/src/VersionControl/actions/gitInitAction.tsx b/packages/example-app/src/VersionControl/actions/gitInitAction.tsx new file mode 100644 index 00000000..55d3aeeb --- /dev/null +++ b/packages/example-app/src/VersionControl/actions/gitInitAction.tsx @@ -0,0 +1,33 @@ +import git from "isomorphic-git"; +import { fs } from "../../fs/fs"; +import { createAction } from "../../createAction"; +import { currentProjectState } from "../../Project/project.state"; +import { VcsActionIds } from "../VcsActionIds"; +import { vcsRootsState } from "../file-status.state"; + +/** + * FIXME: action is not enabled on repo roots. + * FIXME(maybe): if repo status is being updated, action either remains disabled or just doesn't work (didn't check which) + * Maybe not much to do here if the action remains disabled because of unknown status. + * TODO: task API can be used to make this a task. + */ +export const gitInitActionSelector = createAction({ + id: VcsActionIds.GIT_INIT, + title: "Create Git Repository...", + actionPerformed: + ({ snapshot, set }) => + async () => { + // TODO: open a path selector to select the path where the git repo should be initialized + const project = await snapshot.getPromise(currentProjectState); + const dir = project.path; + await git.init({ fs, dir }); + set(vcsRootsState, (roots) => + roots + .filter((root) => root.dir !== dir) + .concat({ + vcs: "Git", + dir, + }) + ); + }, +}); diff --git a/packages/example-app/src/VersionControl/file-status.state.ts b/packages/example-app/src/VersionControl/file-status.state.ts index a73254ef..ab248f7f 100644 --- a/packages/example-app/src/VersionControl/file-status.state.ts +++ b/packages/example-app/src/VersionControl/file-status.state.ts @@ -6,7 +6,7 @@ import { useRecoilCallback, useSetRecoilState, } from "recoil"; -import { sampleRepos } from "../Project/project.state"; +import { defaultProject, sampleRepos } from "../Project/project.state"; import git, { findRoot, statusMatrix } from "isomorphic-git"; import { fs } from "../fs/fs"; import { @@ -15,7 +15,9 @@ import { VcsDirectoryMapping, } from "./file-status"; import * as path from "path"; -import { asyncFilter } from "../async-utils"; +import { notNull } from "@intellij-platform/core/utils/array-utils"; +import { persistentAtomEffect } from "../Project/persistence/persistentAtomEffect"; +import { array, literal, object, string, union } from "@recoiljs/refine"; /** * git.status function has an issue with newly added files. it returns "*added" for both of these cases: @@ -34,22 +36,68 @@ const status = async ({ .then((rows) => rows[0]); }; -export const vcsRootsState = atom({ +interface VcsDirectoryMappingStorage { + mapping?: MaybeArray<{ "@directory": string; "@vcs": "Git" }>; +} +const vcsDirectoryMappingChecker = array( + object({ + dir: string(), + vcs: union(literal("Git")), + }) +); + +type MaybeArray = Array | T; +const maybeArray = (input: MaybeArray | undefined): Array => + Array.isArray(input) ? input : input ? [input] : []; + +export const vcsRootsState = atom>({ key: "vcsRoots", effects: [ - ({ setSelf }) => { - setSelf(TMP_findGitRoots()); - }, + persistentAtomEffect< + ReadonlyArray, + VcsDirectoryMappingStorage + >({ + storageFile: "vcs.xml", + refine: vcsDirectoryMappingChecker, + componentName: "VcsDirectoryMappings", + // TODO: translate project dir to $PROJECT_DIR$ + read: (gitSettings) => { + const mappings = maybeArray(gitSettings?.mapping)?.map((item) => ({ + dir: item["@directory"], + vcs: item["@vcs"], + })); + return mappings.length > 0 ? mappings : TMP_findGitRoots(); + }, + update: + (value) => + (currentValue): VcsDirectoryMappingStorage => ({ + ...(currentValue || {}), + mapping: value.map(({ vcs, dir }) => ({ + "@directory": dir, + "@vcs": vcs, + })), + }), + }), ], }); const TMP_findGitRoots = () => - asyncFilter( - ({ dir }) => fs.promises.stat(dir).then(Boolean), - Object.values(sampleRepos).map(({ path }) => ({ - dir: path, - vcs: "git", - })) - ); + Promise.all( + [...Object.values(sampleRepos), defaultProject].map( + async ({ path: dir }) => { + const exists = await fs.promises.exists(dir); + if (exists) { + return git.findRoot({ fs, filepath: dir }).catch(() => null); + } + } + ) + ).then((roots) => { + return [...new Set(roots.filter(notNull))].map( + (dir) => ({ + dir, + vcs: "Git", + }) + ); + }); /** * temporary(?) hook to refresh vcs roots @@ -72,7 +120,7 @@ export const vcsRootForFile = selectorFamily({ ({ get }) => { // FIXME: use vcsRoots. // return get(vcsRootsState).find( - // (root) => root.vcs === "git" && isParentPath(root.dir, filepath) + // (root) => root.vcs === "Git" && isParentPath(root.dir, filepath) // )?.dir ?? null; // function isParentPath(parent: string, dir: string) { // const relative = path.relative(parent, dir); diff --git a/packages/example-app/src/VersionControl/file-status.ts b/packages/example-app/src/VersionControl/file-status.ts index 0fa24e33..1cd0cd87 100644 --- a/packages/example-app/src/VersionControl/file-status.ts +++ b/packages/example-app/src/VersionControl/file-status.ts @@ -12,7 +12,7 @@ export type FileStatus = // platform/vcs-api/src/com/intellij/openapi/vcs/VcsDirectoryMapping.java export interface VcsDirectoryMapping { dir: string; - vcs: "git"; // only supported vcs for now. + vcs: "Git"; // only supported vcs for now. } // TODO: remove when upgraded TS to >=4.5 diff --git a/packages/example-app/src/VersionControl/useShowGitTipIfNeeded.tsx b/packages/example-app/src/VersionControl/useShowGitTipIfNeeded.tsx index 910903a6..2f7fba93 100644 --- a/packages/example-app/src/VersionControl/useShowGitTipIfNeeded.tsx +++ b/packages/example-app/src/VersionControl/useShowGitTipIfNeeded.tsx @@ -8,7 +8,7 @@ import { sampleRepos, useRefreshCurrentProjectFiles, } from "../Project/project.state"; -import { cloneRepo, isSuccessfullyCloned } from "../SampleRepoInitializer"; +import { cloneRepo, isSuccessfullyCloned } from "../ProjectInitializer"; import React from "react"; import { useRefreshVcsRoots } from "./file-status.state"; import { useRecoilCallback } from "recoil"; diff --git a/packages/example-app/src/VersionControl/useVcsActions.tsx b/packages/example-app/src/VersionControl/useVcsActions.tsx index cbd59e0d..e860da9f 100644 --- a/packages/example-app/src/VersionControl/useVcsActions.tsx +++ b/packages/example-app/src/VersionControl/useVcsActions.tsx @@ -13,15 +13,21 @@ import { VcsActionIds } from "./VcsActionIds"; import { CreateNewBranchWindow } from "./Branches/CreateNewBranchWindow"; import { useExistingLatestRecoilValue } from "../recoil-utils"; import { gitAddActionSelector } from "./actions/gitAddAction"; +import { gitInitActionSelector } from "./actions/gitInitAction"; +import { useRecoilValue } from "recoil"; +import { vcsRootsState } from "./file-status.state"; export function useVcsActions(): ActionDefinition[] { const popupManager = usePopupManager(); const windowManager = useWindowManager(); - const [gitAddAction] = useExistingLatestRecoilValue(gitAddActionSelector); + const [gitInitAction] = useExistingLatestRecoilValue(gitInitActionSelector); return [ ...useChangesViewActionDefinitions(), gitAddAction, + // not including git init action if there is at least one git root, because the action is not fully implemented + // and doesn't allow selecting the directory to initialize as a git repository. FIXME + ...(useRecoilValue(vcsRootsState).length === 0 ? [gitInitAction] : []), { id: VcsActionIds.GIT_CREATE_NEW_BRANCH, title: "New Branch\u2026", diff --git a/packages/example-app/src/fs/fs.state.ts b/packages/example-app/src/fs/fs.state.ts index 953448cb..50e68740 100644 --- a/packages/example-app/src/fs/fs.state.ts +++ b/packages/example-app/src/fs/fs.state.ts @@ -58,8 +58,9 @@ export const dirContentState = selectorFamily({ * @deprecated use reset(fileContent(path)) directly */ export const reloadFileFromDiskCallback = - ({ reset }: CallbackInterface) => + ({ reset, refresh }: CallbackInterface) => async (path: string) => { + refresh(readFileContentSelector(path)); reset(fileContentState(path)); }; diff --git a/packages/jui/_templates/component/new/1.[component].cy-test.tsx.ejs.t b/packages/jui/_templates/component/new/1.[component].cy-test.tsx.ejs.t index 15783c69..4d5c7756 100644 --- a/packages/jui/_templates/component/new/1.[component].cy-test.tsx.ejs.t +++ b/packages/jui/_templates/component/new/1.[component].cy-test.tsx.ejs.t @@ -17,7 +17,7 @@ describe("<%= componentName %>", () => { function matchImageSnapshot(snapshotsName: string) { // with percy - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); // or local snapshot testing diff --git a/packages/jui/cypress.config.ts b/packages/jui/cypress.config.ts index be738e06..17a912b3 100644 --- a/packages/jui/cypress.config.ts +++ b/packages/jui/cypress.config.ts @@ -3,12 +3,17 @@ import { defineConfig } from "cypress"; import { initPlugin } from "cypress-plugin-snapshots/plugin"; import webpackConfig from "./cypress/webpack.config"; +// @ts-expect-error: missing type definition +import addPlaybackTask from "@oreillymedia/cypress-playback/addTasks"; + export default defineConfig({ projectId: "o1ooqz", + component: { setupNodeEvents(on, config) { // TODO: consider moving to https://github.com/FRSOURCE/cypress-plugin-visual-regression-diff initPlugin(on, config); + addPlaybackTask(on, config); return config; }, devServer: { @@ -18,4 +23,19 @@ export default defineConfig({ webpackConfig, }, }, + + e2e: { + env: { + PLAYBACK_MODE: "hybrid", + }, + viewportWidth: 1280, + viewportHeight: 800, + baseUrl: "http://localhost:3000/jui/example-app", + setupNodeEvents(on, config) { + initPlugin(on, config); + addPlaybackTask(on, config); + return config; + // implement node event listeners here + }, + }, }); diff --git a/packages/jui/cypress/cypress.d.ts b/packages/jui/cypress/cypress.d.ts deleted file mode 100644 index 284814b8..00000000 --- a/packages/jui/cypress/cypress.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { mount } from "cypress/react"; - -declare global { - namespace Cypress { - interface Chainable { - mount: typeof mount; - - /** - * Command+click on mac, Ctrl+click, otherwise - */ - ctrlClick( - options?: Partial< - Omit< - Cypress.ClickOptions, - "cmdKey" | "ctrlKey" | "commandKey" | "controlKey" | "metaKey" - > - > - ): Chainable; - } - } -} diff --git a/packages/jui/cypress/e2e/file-actions.cy.ts b/packages/jui/cypress/e2e/file-actions.cy.ts new file mode 100644 index 00000000..c584c8e2 --- /dev/null +++ b/packages/jui/cypress/e2e/file-actions.cy.ts @@ -0,0 +1,89 @@ +import { gitInit } from "../support/example-app"; + +beforeEach(() => { + // cy.playback("GET", /https:\/\/raw\.githubusercontent.com/); +}); + +const deleteFile = (filename: string) => { + cy.findTreeNodeInProjectView(filename).realClick().should("be.focused"); + cy.realPress("Backspace"); + cy.findByRole("button", { name: "Ok" }).realClick(); + cy.findByRole("treeitem", { name: new RegExp(filename) }).should("not.exist"); +}; + +describe("files actions", () => { + it("can create, delete and recreate a file without vcs", () => { + Cypress.on("uncaught:exception", (err, runnable) => { + return !err.message.includes( + "NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope'" + ); + }); + + function createFileWithoutVcs(filename: string) { + cy.createFile(filename); + cy.findByRole("tab", { name: filename, selected: true }); // The new file opened in the editor + cy.get("textarea").should("be.focus").realType("Test content"); // Editor focused + } + cy.initialization(); + + createFileWithoutVcs("test.ts"); + + createFileWithoutVcs("test2.ts"); + + deleteFile("test2.ts"); + + // Focus was on the project tool window and should be restored to it. + // Selection should change from the deleted file to it's parent + cy.findByRole("treeitem", { name: /Workspace/ }).should("be.focused"); + cy.findByRole("tab", { name: "test2.ts", selected: true }).should( + "not.exist" + ); + + createFileWithoutVcs("test2.ts"); + + // Make sure the file content from the deleted file was not cached + cy.get("textarea").should("have.value", "Test content"); + }); + + it("file creation and deletion, with vcs enabled", () => { + Cypress.on("uncaught:exception", (err, runnable) => { + return !err.message.includes( + "NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope'" + ); + }); + + cy.initialization(gitInit()); + + cy.createFile("test.ts"); + cy.findByRole("dialog", { name: "Add File to Git" }); + cy.realPress("Escape"); + cy.findByRole("tab", { name: "test.ts", selected: true }); // The new file opened in the editor + cy.get("textarea").should("be.focus").realType("Test content"); // Editor focused + + cy.createFile("test2.ts"); + cy.findByRole("dialog", { name: "Add File to Git" }); + cy.findByRole("button", { name: "Add" }).realPress("Enter"); + cy.findByRole("tab", { name: "test2.ts", selected: true }); // The new file opened in the editor + cy.get("textarea").should("be.focus").realType("Test content"); // Editor focused + + cy.createFile("test3.ts"); + cy.findByRole("dialog", { name: "Add File to Git" }); + cy.findByRole("button", { name: "Cancel" }).realClick(); + cy.findByRole("tab", { name: "test3.ts", selected: true }); // The new file opened in the editor + cy.get("textarea").should("be.focus").realType("Test content"); // Editor focused + + cy.contains("Commit").click(); + + cy.findByRole("tree", { name: "Commit changes tree" }) + .findAllByRole("treeitem", { name: /test([23])?.ts/ }) + .should("have.length", 3); + + deleteFile("test2.ts"); + + cy.findByRole("tree", { name: "Commit changes tree" }) + .findAllByRole("treeitem", { name: /test([23])?.ts/ }) + .should("have.length", 2); + + cy.percySnapshot(); // To check file statuses + }); +}); diff --git a/packages/jui/cypress/e2e/vcs/branch-switching.cy.ts b/packages/jui/cypress/e2e/vcs/branch-switching.cy.ts new file mode 100644 index 00000000..5c3f7321 --- /dev/null +++ b/packages/jui/cypress/e2e/vcs/branch-switching.cy.ts @@ -0,0 +1,56 @@ +import { + branch, + commit, + file, + fromCurrentBranch, + gitInit, +} from "../../support/example-app"; + +describe("vcs => branch switching", () => { + beforeEach(() => { + cy.initialization( + gitInit( + commit([file("test-on-both-branches.ts")]), + fromCurrentBranch( + branch( + "branch-1", + commit([ + file("test-on-both-branches.ts"), + file("test-on-branch-1.ts"), + ]) + ), + branch( + "branch-2", + commit([ + file("test-on-both-branches.ts"), + file("test-on-branch-2.ts"), + ]) + ) + ) + ) + ); + }); + + it("can switch branches updating files in project view and the editor", () => { + cy.step("Check files and their content on branch-2"); + cy.findTreeNodeInProjectView("test-on-branch-2.ts").dblclick(); + cy.contains("test-on-branch-2.ts content on branch-2"); + cy.findTreeNodeInProjectView("test-on-both-branches.ts").dblclick(); + cy.contains("test-on-both-branches.ts content on branch-2"); + + cy.step("Switch to branch-1"); + cy.focused(); // waiting for the editor (or whatever element) to get focused, so the keyboard events can be handled + cy.searchAndInvokeAction("Branches", "Branches..."); + cy.contains("branch-1").realClick(); + cy.findByRole("menuitem", { name: "Checkout" }).realClick(); + + cy.step("Check files and their content on branch-1"); + cy.findTreeNodeInProjectView("test-on-branch-2.ts").should("not.exist"); + cy.findByRole("tab", { name: "test-on-branch-2.ts" }).should("not.exist"); + cy.findByRole("tab", { name: "test-on-both-branches.ts" }).should("exist"); + cy.findTreeNodeInProjectView("test-on-branch-1.ts").dblclick(); + cy.contains("test-on-branch-1.ts content on branch-1"); + cy.findTreeNodeInProjectView("test-on-both-branches.ts").dblclick(); + cy.contains("test-on-both-branches.ts content on branch-1"); + }); +}); diff --git a/packages/jui/cypress/e2e/vcs/checkin-action.cy.ts b/packages/jui/cypress/e2e/vcs/checkin-action.cy.ts new file mode 100644 index 00000000..095c3d6d --- /dev/null +++ b/packages/jui/cypress/e2e/vcs/checkin-action.cy.ts @@ -0,0 +1,15 @@ +import { gitInit } from "../../support/example-app"; + +describe("vcs => checkin action", () => { + it("opens commit tool window and focuses commit message editor", () => { + cy.initialization(gitInit()); + cy.searchAndInvokeAction("Commit"); + cy.findByPlaceholderText("Commit Message").should("be.focused"); + }); + + it("has the default Cmd+K key mapping", () => { + cy.initialization(gitInit()); + cy.realPress(["Meta", "k"]); + cy.findByPlaceholderText("Commit Message").should("be.focused"); + }); +}); diff --git a/packages/jui/cypress/e2e/vcs/commit.cy.ts b/packages/jui/cypress/e2e/vcs/commit.cy.ts new file mode 100644 index 00000000..4928bca1 --- /dev/null +++ b/packages/jui/cypress/e2e/vcs/commit.cy.ts @@ -0,0 +1,38 @@ +import { file, gitAdd, gitInit } from "../../support/example-app"; + +// NOTE: Different expectations are intentionally bundled to minimize the number of test cases for performance +// reasons, since each e2e test has the overhead of loading the app, which takes a few seconds. +it("commits changes", () => { + cy.initialization(gitInit(gitAdd(file("test.ts")))); + // Waiting for the test file to open in the editor to avoid a flakiness in clicking Commit button. That's because + // the editor sometimes loads while .click() command is being executed on Commit button, and the focus shift from + // Commit toolwindow to the editor makes the button change classes (going from default to non-default variant), + // which prevents cypress from finding the element after it checks it's accessible for clicking. + cy.contains("test.ts content on master"); + cy.contains("Commit").realClick(); + cy.findByRole("tree", { name: "Commit changes tree" }) + .findAllByRole("checkbox", { selected: true }) + .should("have.length", 0); + + cy.step("Check it requires changes to be selected"); + cy.findByRole("button", { name: "Commit" }).click(); + cy.contains("Select files to commit"); + + cy.step("Check it requires commit message"); + cy.findTreeNodeInChangesView("Changes").findByRole("checkbox").click(); + cy.findByRole("button", { name: "Commit" }).click(); + cy.contains("Specify commit message"); + + cy.step("Commit changes"); + cy.findByPlaceholderText("Commit Message").type("test commit message"); + cy.findByRole("button", { name: "Commit" }).click(); + cy.findTreeNodeInChangesView("Changes").should("contain", "No files"); + cy.contains("1 file committed: test commit message"); + + cy.step("Check the commit is shown in Git toolwindow"); + cy.contains("Version Control").click(); + cy.findByLabelText("Commits list").contains("test commit message").click(); + cy.findByLabelText("Commit changes") + .findByRole("treeitem", { name: "test.ts" }) + .should("have.fileStatusColor", "new"); +}); diff --git a/packages/jui/cypress/e2e/vcs/file-status-colors.cy.ts b/packages/jui/cypress/e2e/vcs/file-status-colors.cy.ts new file mode 100644 index 00000000..68db2b22 --- /dev/null +++ b/packages/jui/cypress/e2e/vcs/file-status-colors.cy.ts @@ -0,0 +1,95 @@ +import { + commit, + deleteFile, + dir, + file, + gitAdd, + gitInit, + persistedGitSettings, +} from "../../support/example-app"; + +describe("vcs => file status colors", () => { + const withDifferentChangeTypes = gitInit( + commit([file("modified-file.ts"), file("removed-file.ts")]), + file("modified-file.ts", "updated content"), + gitAdd(file("new-file.ts")), + file("unversioned-file.ts"), + deleteFile("removed-file.ts") + ); + + const checkColors = () => { + cy.step("Colors in project tool window"); + cy.findTreeNodeInProjectView("modified-file.ts").should( + "have.fileStatusColor", + "modified" + ); + cy.findTreeNodeInProjectView("new-file.ts").should( + "have.fileStatusColor", + "new" + ); + cy.findTreeNodeInProjectView("unversioned-file.ts").should( + "have.fileStatusColor", + "unversioned" + ); + + cy.step("Colors in Commit tool window"); + cy.contains("Commit").realClick(); + cy.findTreeNodeInChangesView("modified-file.ts").should( + "have.fileStatusColor", + "modified" + ); + cy.findTreeNodeInChangesView("new-file.ts").should( + "have.fileStatusColor", + "new" + ); + cy.findTreeNodeInChangesView("removed-file.ts").should( + "have.fileStatusColor", + "deleted" + ); + cy.findTreeNodeInChangesView("unversioned-file.ts").should( + "have.fileStatusColor", + "unversioned" + ); + + cy.step("Colors in editor tabs"); + cy.findByRole("tab", { name: "modified-file.ts" }).should( + "have.fileStatusColor", + "modified" + ); + cy.findByRole("tab", { name: "new-file.ts" }).should( + "have.fileStatusColor", + "new" + ); + cy.findByRole("tab", { name: "unversioned-file.ts" }).should( + "have.fileStatusColor", + "unversioned" + ); + }; + + it("shows the right color in different places, if files are in a git repo", () => { + cy.initialization(withDifferentChangeTypes); + checkColors(); + }); + + it("shows the right colors when some folders in workspace are git repos and some are not", () => { + cy.initialization( + dir("some-repo", [withDifferentChangeTypes]), + file("file-outside-git-root.txt"), + persistedGitSettings({ gitRoots: ["some-repo"] }) + ); + checkColors(); + + cy.findByRole("tab", { name: "file-outside-git-root.txt" }).should( + "have.fileStatusColor", + "unmodified" + ); + + cy.findTreeNodeInProjectView("file-outside-git-root.txt").should( + "have.fileStatusColor", + "unmodified" + ); + cy.findTreeNodeInChangesView("file-outside-git-root.txt").should( + "not.exist" + ); + }); +}); diff --git a/packages/jui/cypress/e2e/vcs/nested-git-repos.cy.ts b/packages/jui/cypress/e2e/vcs/nested-git-repos.cy.ts new file mode 100644 index 00000000..ed04ff97 --- /dev/null +++ b/packages/jui/cypress/e2e/vcs/nested-git-repos.cy.ts @@ -0,0 +1,39 @@ +import { + commit, + dir, + file, + gitAdd, + gitInit, + persistedGitSettings, +} from "../../support/example-app"; + +const sampleRepo = gitInit( + commit([file("modified-file.ts")]), + gitAdd(file("modified-file.ts", "updated content")) +); +describe("vcs => file status colors", () => { + /** + * FIXME: fix the issues and unskip the test below. There are two issues currently: + * - The change nodes of the nested repo are repeated in the parent repo tree, if changes are grouped by repo + * - git.statusMatrix reports the files in the nested repo as "new, untracked" + */ + it.skip("doesn't show the changes of a nested repo in the outer repo", () => { + cy.initialization( + dir("parent-repo", [sampleRepo]), + dir("parent-repo/nested-repo", [sampleRepo]), + persistedGitSettings({ + gitRoots: ["parent-repo", "parent-repo/nested-repo"], + }) + ); + cy.contains("Commit").click(); + cy.findTreeNodeInChangesView("/workspace/parent-repo/nested-repo").should( + "contain.text", + "1 file" + ); + + cy.findTreeNodeInChangesView("/workspace/parent-repo").should( + "contain.text", + "1 file" + ); // not two files + }); +}); diff --git a/packages/jui/cypress/e2e/vcs/refresh-action.cy.ts b/packages/jui/cypress/e2e/vcs/refresh-action.cy.ts new file mode 100644 index 00000000..791251df --- /dev/null +++ b/packages/jui/cypress/e2e/vcs/refresh-action.cy.ts @@ -0,0 +1,14 @@ +import { file, gitInit } from "../../support/example-app"; + +describe("vcs => refresh action", () => { + it("picks up externally changed files", () => { + cy.initialization(gitInit(file("test.ts"))); + cy.contains("Commit").realClick(); + cy.findByRole("button", { name: /Refresh/ }).realClick(); + cy.findTreeNodeInChangesView("test.ts"); + cy.findTreeNodeInChangesView("Unversioned Files").should( + "contain.text", + "1 file" + ); + }); +}); diff --git a/packages/jui/cypress/e2e/vcs/rollback.cy.ts b/packages/jui/cypress/e2e/vcs/rollback.cy.ts new file mode 100644 index 00000000..74b90855 --- /dev/null +++ b/packages/jui/cypress/e2e/vcs/rollback.cy.ts @@ -0,0 +1,126 @@ +import { + commit, + deleteFile, + file, + gitAdd, + gitInit, +} from "../../support/example-app"; + +describe("vcs => rollback", () => { + const withDifferentChangeTypes = gitInit( + commit([file("existing-file.ts"), file("removed-file.ts")]), + file("existing-file.ts", "updated content"), + gitAdd(file("new-file.ts")), + deleteFile("removed-file.ts") + ); + + it.only("can rollback a combination of additions and modifications, not deleting local copies", () => { + cy.initialization(withDifferentChangeTypes); + + cy.step("Verify initial state"); + cy.findTreeNodeInProjectView("existing-file.ts"); + cy.findTreeNodeInProjectView("new-file.ts"); + cy.findTreeNodeInProjectView("removed-file.ts").should("not.exist"); + cy.contains("Commit").realClick(); + cy.findTreeNodeInChangesView("existing-file.ts"); + cy.findTreeNodeInChangesView("new-file.ts"); + cy.findTreeNodeInChangesView("removed-file.ts"); + + cy.step("Rollback (without deletion)"); + cy.findTreeNodeInChangesView("Changes").focus(); + cy.searchAndInvokeAction("rollback"); + cy.findByRole("button", { name: "Rollback" }).realClick(); + + cy.step("Verify the changes are reverted"); + cy.findTreeNodeInProjectView("existing-file.ts"); + cy.findTreeNodeInProjectView("removed-file.ts"); + cy.findTreeNodeInProjectView("new-file.ts"); + cy.findTreeNodeInChangesView("Changes").should("contain.text", "No files"); + cy.findTreeNodeInChangesView("Unversioned Files").should( + "contain.text", + "1 file" + ); + cy.findTreeNodeInChangesView("new-file.ts"); + cy.findByRole("tab", { name: "existing-file.ts" }); // The editor tab should remain open + cy.findByRole("tab", { name: "new-file.ts" }); // The editor tab should remain open + }); + + it("can rollback a combination of additions and modifications, deleting local copies", () => { + cy.initialization(withDifferentChangeTypes); + + cy.step("Verify initial state"); + cy.findTreeNodeInProjectView("existing-file.ts"); + cy.findTreeNodeInProjectView("new-file.ts"); + cy.findTreeNodeInProjectView("removed-file.ts").should("not.exist"); + cy.contains("Commit").realClick(); + // TODO: check file status colors https://stackoverflow.com/questions/66163312/how-to-judge-if-a-color-is-green + cy.findTreeNodeInChangesView("existing-file.ts"); + cy.findTreeNodeInChangesView("new-file.ts"); + cy.findTreeNodeInChangesView("removed-file.ts"); + + cy.step("Rollback (without deletion)"); + cy.searchAndInvokeAction("rollback"); + cy.findByRole("checkbox", { name: /Delete local copies/ }) + .realClick() + .should("be.checked"); + cy.findByRole("button", { name: "Rollback" }).realClick(); + + cy.step("Verify the files are reverted, and new files are deleted"); + cy.findTreeNodeInProjectView("existing-file.ts"); + cy.findTreeNodeInProjectView("removed-file.ts"); + cy.findTreeNodeInProjectView("new-file.ts").should("not.exist"); + + cy.step("Verify the changes are reverted, and new files are deleted"); + cy.findTreeNodeInChangesView("new-file.ts").should("not.exist"); + cy.findTreeNodeInChangesView("Changes").should("contain.text", "No files"); + cy.findTreeNodeInChangesView("Unversioned Files").should("not.exist"); + cy.findByRole("tab", { name: "existing-file.ts" }); // The editor tab should remain open + cy.findByRole("tab", { name: "new-file.ts" }).should("not.exist"); // The editor tab should get closed + }); + + it("can delete local copies when rolling back added files", () => { + cy.initialization(gitInit(gitAdd(file("test.ts")))); + + cy.step("Verify initial state"); + cy.findTreeNodeInProjectView("test.ts"); + cy.contains("Commit").realClick(); + cy.findTreeNodeInChangesView("test.ts"); + + cy.step("Rollback (deleting local copies)"); + cy.searchAndInvokeAction("rollback"); + cy.findByRole("checkbox", { name: /Delete local copies/ }) + .realClick() + .should("be.checked"); + cy.findByRole("button", { name: "Rollback" }).realClick(); + + cy.step("Verify the file is deleted"); + cy.findTreeNodeInProjectView("test.ts").should("not.exist"); + cy.findTreeNodeInChangesView("test.ts").should("not.exist"); + cy.findByRole("tab", { name: "test.ts" }).should("not.exist"); // The editor tab should be closed + }); + + it("can keep local copies when rolling back added files", () => { + cy.initialization(gitInit(gitAdd(file("test.ts")))); + + cy.step("Verify initial state"); + cy.findTreeNodeInProjectView("test.ts"); + cy.contains("Commit").realClick(); + cy.findTreeNodeInChangesView("test.ts"); + + cy.step("Rollback (without deletion)"); + cy.searchAndInvokeAction("rollback"); + cy.findByRole("button", { name: "Rollback" }).realClick(); + + cy.step("Verify the added file still exists"); + cy.findTreeNodeInProjectView("test.ts"); + cy.findTreeNodeInChangesView("test.ts"); + + cy.step("Verify the file is now untracked"); + cy.findTreeNodeInChangesView("Changes").should("contain.text", "No files"); + cy.findTreeNodeInChangesView("Unversioned Files").should( + "contain.text", + "1 file" + ); + cy.findByRole("tab", { name: "test.ts" }); // The editor tab should remain open + }); +}); diff --git a/packages/jui/integration-tests/components-in-modal-window.cy.tsx b/packages/jui/cypress/integration/components-in-modal-window.cy.tsx similarity index 97% rename from packages/jui/integration-tests/components-in-modal-window.cy.tsx rename to packages/jui/cypress/integration/components-in-modal-window.cy.tsx index 713325b8..01576e09 100644 --- a/packages/jui/integration-tests/components-in-modal-window.cy.tsx +++ b/packages/jui/cypress/integration/components-in-modal-window.cy.tsx @@ -7,7 +7,7 @@ import { Tree, WindowLayout, } from "@intellij-platform/core"; -import darculaThemeJson from "../themes/darcula.theme.json"; +import darculaThemeJson from "../../themes/darcula.theme.json"; import { Item } from "@react-stately/collections"; describe("integration of modal window with Tree components", () => { diff --git a/packages/jui/integration-tests/mnemonic-and-modals.cy.tsx b/packages/jui/cypress/integration/mnemonic-and-modals.cy.tsx similarity index 100% rename from packages/jui/integration-tests/mnemonic-and-modals.cy.tsx rename to packages/jui/cypress/integration/mnemonic-and-modals.cy.tsx diff --git a/packages/jui/integration-tests/modal-window-and-tree.cy.tsx b/packages/jui/cypress/integration/modal-window-and-tree.cy.tsx similarity index 89% rename from packages/jui/integration-tests/modal-window-and-tree.cy.tsx rename to packages/jui/cypress/integration/modal-window-and-tree.cy.tsx index 1e6db4ae..1ea0b481 100644 --- a/packages/jui/integration-tests/modal-window-and-tree.cy.tsx +++ b/packages/jui/cypress/integration/modal-window-and-tree.cy.tsx @@ -1,6 +1,6 @@ import React from "react"; import { ModalWindow, styled, WindowLayout } from "@intellij-platform/core"; -import { SpeedSearchTreeSample } from "../src/story-components"; +import { SpeedSearchTreeSample } from "@intellij-platform/core/story-components"; const StyledContainer = styled.div` box-sizing: border-box; @@ -38,6 +38,6 @@ describe("ModalWindow containing Tree", () => { }); function matchImageSnapshot(snapshotsName: string) { - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); } diff --git a/packages/jui/integration-tests/modal-window_menu.cy.tsx b/packages/jui/cypress/integration/modal-window_menu.cy.tsx similarity index 100% rename from packages/jui/integration-tests/modal-window_menu.cy.tsx rename to packages/jui/cypress/integration/modal-window_menu.cy.tsx diff --git a/packages/jui/integration-tests/popup-and-menu.cy.tsx b/packages/jui/cypress/integration/popup-and-menu.cy.tsx similarity index 92% rename from packages/jui/integration-tests/popup-and-menu.cy.tsx rename to packages/jui/cypress/integration/popup-and-menu.cy.tsx index f2ebc072..11ac14db 100644 --- a/packages/jui/integration-tests/popup-and-menu.cy.tsx +++ b/packages/jui/cypress/integration/popup-and-menu.cy.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Button, Popup, PopupTrigger } from "@intellij-platform/core"; -import { MenuPopupContent } from "../src/Popup/story-helpers"; +import { MenuPopupContent } from "@intellij-platform/core/Popup/story-helpers"; describe("Popup and menu integration", () => { it("lets user select menu items by mouse", () => { diff --git a/packages/jui/integration-tests/tool-windows-and-actions.cy.tsx b/packages/jui/cypress/integration/tool-windows-and-actions.cy.tsx similarity index 98% rename from packages/jui/integration-tests/tool-windows-and-actions.cy.tsx rename to packages/jui/cypress/integration/tool-windows-and-actions.cy.tsx index 01778cbe..42192c96 100644 --- a/packages/jui/integration-tests/tool-windows-and-actions.cy.tsx +++ b/packages/jui/cypress/integration/tool-windows-and-actions.cy.tsx @@ -7,7 +7,7 @@ import { toolWindowState, DefaultToolWindows, } from "@intellij-platform/core"; -import darculaThemeJson from "../themes/darcula.theme.json"; +import darculaThemeJson from "../../themes/darcula.theme.json"; import { SpeedSearchTreeSample } from "@intellij-platform/core/story-components"; const window = (id: string) => ({ diff --git a/packages/jui/integration-tests/tool-windows-and-tooltips.cy.tsx b/packages/jui/cypress/integration/tool-windows-and-tooltips.cy.tsx similarity index 96% rename from packages/jui/integration-tests/tool-windows-and-tooltips.cy.tsx rename to packages/jui/cypress/integration/tool-windows-and-tooltips.cy.tsx index da540fb8..646a63f6 100644 --- a/packages/jui/integration-tests/tool-windows-and-tooltips.cy.tsx +++ b/packages/jui/cypress/integration/tool-windows-and-tooltips.cy.tsx @@ -9,7 +9,7 @@ import { ToolWindowsState, toolWindowState, } from "@intellij-platform/core"; -import darculaThemeJson from "../themes/darcula.theme.json"; +import darculaThemeJson from "../../themes/darcula.theme.json"; const window = (id: string) => ({ id, diff --git a/packages/jui/cypress/support/component.tsx b/packages/jui/cypress/support/component.tsx index 43b86943..ab2f79ed 100644 --- a/packages/jui/cypress/support/component.tsx +++ b/packages/jui/cypress/support/component.tsx @@ -1,3 +1,4 @@ +/// // *********************************************************** // This example support/index.ts is processed and // loaded automatically before your test files. @@ -13,9 +14,7 @@ // https://on.cypress.io/configuration // *********************************************************** -import "@percy/cypress"; -import "cypress-real-events/support"; -import "./commands"; +import "./shared"; import React, { useEffect } from "react"; import { setProjectAnnotations } from "@storybook/react"; @@ -23,6 +22,14 @@ import { mount, MountOptions } from "cypress/react"; import { Theme, ThemeProvider } from "@intellij-platform/core"; import sbPreview from "../../.storybook/preview"; +declare global { + namespace Cypress { + interface Chainable { + mount: typeof mount; + } + } +} + const requireTheme = require.context("../../themes", false, /\.theme\.json$/); const themes: Theme[] = requireTheme.keys().map((themeFile: string) => { const themeJson = requireTheme(themeFile); @@ -45,23 +52,6 @@ const TestThemeProvider = ({ return {children}; }; -const originalDispatchEvent = window.dispatchEvent; - -Cypress.Screenshot.defaults({ - onBeforeScreenshot: () => { - window.dispatchEvent = (e) => { - console.log( - "Ignored event dispatched during snapshot testing. That's to prevent overlays from getting closed on scroll event", - e - ); - return false; - }; - }, - onAfterScreenshot: () => { - window.dispatchEvent = originalDispatchEvent; - }, -}); - Cypress.Commands.add( "mount", ( diff --git a/packages/jui/cypress/support/e2e.ts b/packages/jui/cypress/support/e2e.ts new file mode 100644 index 00000000..3c216364 --- /dev/null +++ b/packages/jui/cypress/support/e2e.ts @@ -0,0 +1,24 @@ +// *********************************************************** +// This example support/e2e.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import "./shared"; +import "./example-app/support"; + +Cypress.on("uncaught:exception", (err) => { + if (/ResizeObserver loop limit exceeded/.test(err.message)) { + return false; + } +}); diff --git a/packages/jui/cypress/support/example-app/AppGlobals.ts b/packages/jui/cypress/support/example-app/AppGlobals.ts new file mode 100644 index 00000000..7965cfa6 --- /dev/null +++ b/packages/jui/cypress/support/example-app/AppGlobals.ts @@ -0,0 +1,6 @@ +export type AppGlobals = { + fs: typeof import("../../../../example-app/src/fs/fs").fs; + git: typeof import("isomorphic-git"); + path: typeof import("path"); + projectDir: string; +}; diff --git a/packages/jui/cypress/support/example-app/commands.ts b/packages/jui/cypress/support/example-app/commands.ts new file mode 100644 index 00000000..00a9c6b6 --- /dev/null +++ b/packages/jui/cypress/support/example-app/commands.ts @@ -0,0 +1,101 @@ +/// +/// + +import { AppGlobals } from "./AppGlobals"; + +declare global { + namespace Cypress { + interface Chainable { + searchAndInvokeAction( + actionName: string, + search?: string + ): Chainable; + + /** + * Creates a file via Projects tool window UI. Assumes Project tool window is open. + */ + createFile(filename: string): Chainable; + findTreeNodeInProjectView( + filename: string + ): Chainable>; + findTreeNodeInChangesView( + filename: string + ): Chainable>; + + /** + * Loads the app's URL, and runs a number of initializer functions in parallel. + * + * @param init + */ + initialization( + ...init: Array<(params: AppGlobals) => unknown | Promise> + ): Chainable; + } + } +} + +Cypress.Commands.add("searchAndInvokeAction", searchAndInvokeAction); +Cypress.Commands.add("createFile", createFile); +Cypress.Commands.add("findTreeNodeInProjectView", findTreeNodeInProjectView); +Cypress.Commands.add("findTreeNodeInChangesView", findTreeNodeInChangesView); +Cypress.Commands.add("initialization", initialize); + +function initialize( + ...initializeFns: Array<(params: AppGlobals) => Promise | unknown> +) { + cy.visit("/"); + cy.window().then((win) => { + return ((win as any).INITIALIZE_APP = ({ fs, git, path }: AppGlobals) => + Promise.all( + initializeFns.map(async (fn) => + fn({ + fs, + git, + path, + projectDir: "/workspace", + }) + ) + )); + }); +} + +function searchAndInvokeAction( + actionName: string, + search: string = actionName +) { + cy.realPress(["Meta", "Shift", "A"]); + cy.findByRole("dialog"); + cy.realType(search); + cy.findAllByRole("listitem", { + name: new RegExp(actionName, "ig"), + }) + .filter("[aria-selected=true]") // "aria-selected" is not supported on role "listitem" :/ maybe FIXME in the list component + .should("have.length", 1); + cy.realPress("Enter"); +} + +function findTreeNodeInProjectView(filename: string) { + return cy + .findByRole("tree", { name: "Project structure tree" }) + .findByRole("treeitem", { name: new RegExp(filename) }); +} + +function findTreeNodeInChangesView(filename: string) { + cy.findByRole("tree", { name: "Commit changes tree" }).findByRole( + "treeitem", + { name: new RegExp(filename) } + ); +} + +function createFile(filename: string) { + cy.findByRole("tree", { name: "Project structure tree" }) + .findAllByRole("treeitem", { name: /workspace/i }) + .first() + .click() + .should("be.focused"); + + cy.searchAndInvokeAction("File", "create file"); + cy.findByPlaceholderText("Name").should("be.focused"); + cy.realType(filename, { delay: 1 }); + cy.realPress("Enter"); +} diff --git a/packages/jui/cypress/support/example-app/fileStatusColor.ts b/packages/jui/cypress/support/example-app/fileStatusColor.ts new file mode 100644 index 00000000..999c857a --- /dev/null +++ b/packages/jui/cypress/support/example-app/fileStatusColor.ts @@ -0,0 +1,103 @@ +export {}; // without import/export the file is not considered a module and `declare global` will be a type error + +declare global { + namespace Cypress { + interface Chainer { + /** + * Asserts that the target element's text color is the given file status color. + * @example + * cy.findByRole("tab", {name: "test.ts"}).should('have.fileStatusColor', 'modified') + */ + ( + chainer: "have.fileStatusColor", + expectedColor: + | "new" + | "unmodified" + | "modified" + | "deleted" + | "unversioned" + ): Cypress.Chainable; + } + } +} + +chai.Assertion.addMethod("fileStatusColor", function (expectedColor) { + const element = this._obj; + + // Use jQuery to get the computed CSS style of the element + const mapping = { + red: "unversioned", + mutedGrey: "deleted", + blue: "modified", + grey: "unmodified", + green: "new", + } as const; + + const $element = Cypress.$(element); + const { color, estimatedColor } = + $element + .add($element.find("*")) + .map(function () { + const element: Cypress.JQueryWithSelector = Cypress.$(this); + if (element.text().trim()) { + const color = element.css("color"); + const colorEstimation = getColorEstimation(color); + if (colorEstimation) { + return { color, estimatedColor: colorEstimation }; + } + } + }) + .last()[0] ?? {}; + if (!estimatedColor) { + throw new Error( + `could not match the color with any file status color: ${color}` + ); + } + const actualColor = mapping[estimatedColor]; + + // Assert that the actual color matches the expected color + this.assert( + actualColor === expectedColor, + `expected element to have file status color #{exp} but got #{act}`, + `expected element not to have file status color #{exp}`, + expectedColor, // expected + actualColor // actual + ); +}); + +function getColorEstimation( + rgbStr: string +): "red" | "green" | "blue" | "grey" | "mutedGrey" | undefined { + const [_, r, g, b] = + rgbStr.match(/rgba?\((\d{1,3})\s?,\s?(\d{1,3}),\s?(\d{1,3})\)/) || []; + if (!r || !g || !b) { + throw new Error(`Unexpected color format: ${rgbStr}`); + } + const color = [r, g, b].map((str) => parseInt(str)) as [ + number, + number, + number + ]; + return Object.entries(colorEstimations).find(([_, matches]) => + matches(color) + )?.[0] as keyof typeof colorEstimations; +} +const colorEstimations = { + green: ([red, green, blue]: [number, number, number]) => + blue < 0.85 * green && red < 0.85 * green, + blue: ([red, green, blue]: [number, number, number]) => + green < 0.85 * blue && red < 0.85 * blue, + red: ([red, green, blue]: [number, number, number]) => + green < 0.85 * red && blue < 0.85 * red, + mutedGrey: ([red, green, blue]: [number, number, number]) => { + return colorEstimations.grey([red, green, blue]) && red < 130; + }, + grey: ([red, green, blue]: [number, number, number]) => { + const tolerance = 15; // Tolerance for how similar the values need to be + return ( + Math.abs(red - green) < tolerance && + Math.abs(red - blue) < tolerance && + Math.abs(green - blue) < tolerance + ); + }, +}; diff --git a/packages/jui/cypress/support/example-app/index.ts b/packages/jui/cypress/support/example-app/index.ts new file mode 100644 index 00000000..f7c9460b --- /dev/null +++ b/packages/jui/cypress/support/example-app/index.ts @@ -0,0 +1 @@ +export * from "./initializers"; diff --git a/packages/jui/cypress/support/example-app/initializers.ts b/packages/jui/cypress/support/example-app/initializers.ts new file mode 100644 index 00000000..259affd5 --- /dev/null +++ b/packages/jui/cypress/support/example-app/initializers.ts @@ -0,0 +1,272 @@ +import path from "path"; +import { FSModuleWithPromises } from "jui-example-app/src/fs/browser-fs"; +import { AppGlobals } from "./AppGlobals"; + +type InitializationContext = { currentBranch?: string; dir?: string }; + +/** + * Composable helpers to be used with cy.initialization() to create an initial state. + * Each helper typically returns a {@link Change} function with a standard interface, that creates some + * side effect (creating a branch, writing a file, deleting a file, etc.). Some helpers also accept some + * further {@link Change}s to apply, which allows for composing different initialization helpers to create + * different states, in a declarative and readable way. + * + * API design goals: + * - Initialization functions could be composed together, with a minimal assumption about which can be combined with which. + * - Composition of initialization functions should result in initialization functions with a similar signature, making + * it possible to extract specific composition of the functions and reusing them. For example, it should be possible + * to create an initialization function that "creates two parallel branches off of the current branch, each with one + * commit adding a new file", and reuse it on different states. + * - Composition of different functions should be readable + * - Composition of different functions should be intuitive to write, when thinking about a specific state + * + * + * @example initialize an empty git repo + * ```ts + * cy.initialization(gitInit()) + * ``` + * + * @example initialize an empty git repo, and then create a file without committing + * ```ts + * cy.initialization(gitInit(file("test.ts")) + * ``` + * + * @example initialize an empty git repo, and then commit some change on the default branch ("master") + * ```ts + * cy.initialization(gitInit(commit([file("test.ts")]))) + * ``` + * @example initialize git repo, with some file on "master" branch, and two branches created off of master branch, + * each including an extra file. + * ```ts + * cy.initialization( + * gitInit( + * commit([file("test-on-master.ts")]), + * branch( + * "branch-1", + * commit([ + * file("test-on-branch-1.ts"), + * ]) + * ), + * branch( + * "branch-2", + * commit([ + * file("test-on-branch-2.ts"), + * ]) + * ) + * ) + * ) + * ``` + */ + +type Change = ( + args: AppGlobals, + context?: C +) => Promise; + +type FileChange = Change; + +/** + * Creates an initializer which initializes git in project directory ("/workspace" by default), + * running each change passed change on "master" branch.gitInit + * @param changes changes to run on "master", after git init. + */ +export function gitInit(...changes: Array): Change { + return async (args, { dir = args.projectDir } = {}) => { + const { git, fs } = args; + console.log("calling init on", dir); + await git.init({ fs, dir }); + await git.commit({ + fs, + dir, + author: { name: "Ali" }, + message: "initial empty commit", + }); + + for (const change of changes) { + await change(args, { dir, currentBranch: "master" }); + } + }; +} + +/** + * Creates an initializer which creates a branch, and runs further changes on it. + * @param branchName branch name to create + * @param changes further changes to run on after branch is created. Typically, changes created by {@link commit} calls. + */ +export function branch(branchName: string, ...changes: Array): Change { + return async ( + args, + { dir = args.projectDir }: InitializationContext = {} + ) => { + const { git, fs } = args; + await git.branch({ fs, dir, ref: branchName, checkout: true }); + for (const change of changes) { + await change(args, { currentBranch: branchName, dir }); + } + }; +} + +/** + * Creates an initializer which applies a number of initializers on the current branch. Before each initializer is + * applied, it checks out the current branch. A typical use case is to create a bunch of parallel branches, off of the + * current branch + * @param changes further changes to run on after branch is created. Typically, changes created by {@link commit} calls. + */ +export function fromCurrentBranch(...changes: Array): Change { + return async ( + args, + { dir = args.projectDir }: InitializationContext = {} + ) => { + const { git, fs } = args; + const currentBranch = (await git.currentBranch({ fs, dir })) ?? "master"; + for (const change of changes) { + await git.checkout({ fs, dir, ref: currentBranch }); + await change(args, { dir, currentBranch }); + } + }; +} + +/** + * Creates an initializer which creates a commit, after applying some {@link fileChanges}. + * @param fileChanges initializers that make some change on the fs and resolve to the affected file path. + * @param params commit parameters + */ +export function commit( + fileChanges: Array, + params: Omit< + Partial[0]>, + "fs" | "dir" + > = {} +): Change { + return async (args, context) => { + const { git, fs } = args; + const filepaths = await gitAdd(...fileChanges)(args, context); + if (filepaths.length > 0) { + await git.commit({ + fs, + dir: context?.dir ?? args.projectDir, + author: { ...params.author, name: "Ali" }, + message: context?.currentBranch + ? `Test commit on ${context?.currentBranch}` + : "Test commit", + ...params, + }); + } + }; +} + +export function gitAdd(...fileChanges: FileChange[]): Change { + return async (args, context) => { + const { git, fs } = args; + const paths = await Promise.all( + fileChanges.map((fileChange) => fileChange(args, context)) + ); + if (paths.length > 0) { + await git.add({ + fs, + dir: context?.dir ?? args.projectDir, + filepath: paths, + }); + } + + return paths; + }; +} + +/** + * Creates an initializer which creates a directory. + * @param dirname directory name to create + * @param changes further changes to run within the context of the created directory. + */ +export function dir(dirname: string, changes: Change[]): Change { + return async (args, context) => { + const { fs, path, projectDir } = args; + const dir = path.join(context?.dir ?? projectDir, dirname); + await ensureDir(fs, dir); + for (const change of changes) { + await change(args, { ...context, dir }); + } + }; +} + +/** + * Creates an initializer which writes a file on a path specified by {@link filename} + * @param filename path of the file to write, relative to the project directory. + * @param content the content of the file. The default content will indicate filename and the current branch + */ +export function file(filename: string, content?: string): FileChange { + return async ({ fs, path, projectDir }, context) => { + const fullpath = path.join(context?.dir ?? projectDir, filename); + await ensureDir(fs, path.dirname(fullpath)); + await fs.promises.writeFile( + fullpath, + content ?? + (context?.currentBranch + ? `${filename} content on ${context?.currentBranch}` + : `${filename} content`) + ); + return filename; + }; +} + +/** + * Creates an initializer which deletes a file on a path specified by {@link filename} + * @param filename path of the file to delete, relative to the project directory. + */ +export function deleteFile(filename: string): FileChange { + return async ({ fs, path, projectDir }, context) => { + await fs.promises.unlink(path.join(context?.dir ?? projectDir, filename)); + return filename; + }; +} + +/** + * Creates an initializer which writes the XML file corresponding the persisted git configuration + * @param gitRoots: paths used to initialize vcs mappings settings. + */ +export function persistedGitSettings({ + gitRoots, +}: { + gitRoots: string[]; +}): Change { + return async (args, context) => { + const { projectDir, path } = args; + return file( + ".idea/vcs.xml", + ` + + + ${gitRoots + .map( + (gitRoot) => + `` + ) + .join("\n ")} + +` + )(args, context); + }; +} + +async function ensureDir(fs: FSModuleWithPromises, dirPath: string) { + console.log("ensuring directory", dirPath); + const stat = await fs.promises + .stat(dirPath) + .catch((e) => (e.code === "ENOENT" ? false : Promise.reject(e))); + if (stat === false) { + // path doesn't exist + const dirname = path.dirname(dirPath); + if (dirname !== path.dirname(dirname)) { + // not root path + await ensureDir(fs, dirname); + } + await fs.promises + .mkdir(dirPath) + // it can happen that due to async nature of this function, between calling stat and this line, the folder is already created. + .catch((e) => + e.code === "EEXIST" ? Promise.resolve() : Promise.reject(e) + ); + } else if (!stat.isDirectory()) { + throw new Error(`path is not a directory, but already exists: ${dirPath}`); + } +} diff --git a/packages/jui/cypress/support/example-app/support.ts b/packages/jui/cypress/support/example-app/support.ts new file mode 100644 index 00000000..ae0b8ebb --- /dev/null +++ b/packages/jui/cypress/support/example-app/support.ts @@ -0,0 +1,2 @@ +import "./commands"; +import "./fileStatusColor"; diff --git a/packages/jui/cypress/support/commands.ts b/packages/jui/cypress/support/shared.ts similarity index 76% rename from packages/jui/cypress/support/commands.ts rename to packages/jui/cypress/support/shared.ts index 2349cd1e..48916be5 100644 --- a/packages/jui/cypress/support/commands.ts +++ b/packages/jui/cypress/support/shared.ts @@ -24,18 +24,19 @@ // -- This will overwrite an existing command -- // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) +import "cypress-real-events/support"; import "cypress-plugin-snapshots/commands"; import "@testing-library/cypress/add-commands"; +import "@oreillymedia/cypress-playback/addCommands"; +import "cypress-plugin-xhr-toggle"; +import "@percy/cypress"; +import "cypress-plugin-steps"; import { isMac } from "@react-aria/utils"; -Cypress.Commands.add("ctrlClick", { prevSubject: true }, (subject, options) => { - cy.wrap(subject).click({ ...options, metaKey: isMac(), ctrlKey: !isMac() }); -}); - // resize commands declare global { namespace Cypress { - interface Chainable { + interface Chainable { /** * Custom command to perform resize action on an element with resize handle on `side` * @example cy.resizeFromSide('left', 50) // increase size by 50 pixels, using left resize handle. @@ -53,10 +54,26 @@ declare global { move(x: number, y: number): Chainable>; isWithinViewport(): Chainable>; + + /** + * Command+click on mac, Ctrl+click, otherwise + */ + ctrlClick( + options?: Partial< + Omit< + Cypress.ClickOptions, + "cmdKey" | "ctrlKey" | "commandKey" | "controlKey" | "metaKey" + > + > + ): Chainable; } } } +Cypress.Commands.add("ctrlClick", { prevSubject: true }, (subject, options) => { + cy.wrap(subject).click({ ...options, metaKey: isMac(), ctrlKey: !isMac() }); +}); + Cypress.Commands.add( "resizeFromSide", { prevSubject: "element" }, @@ -73,6 +90,23 @@ Cypress.Commands.add( } ); +const originalDispatchEvent = window.dispatchEvent; + +Cypress.Screenshot.defaults({ + onBeforeScreenshot: () => { + window.dispatchEvent = (e) => { + console.log( + "Ignored event dispatched during snapshot testing. That's to prevent overlays from getting closed on scroll event", + e + ); + return false; + }; + }, + onAfterScreenshot: () => { + window.dispatchEvent = originalDispatchEvent; + }, +}); + Cypress.Commands.add("move", { prevSubject: "element" }, (subject, x, y) => { return cy .wrap(subject) diff --git a/packages/jui/package.json b/packages/jui/package.json index de6531b5..ea14b172 100644 --- a/packages/jui/package.json +++ b/packages/jui/package.json @@ -26,16 +26,16 @@ "storybook:build": "storybook build", "storybook:typecheck": "tsc --project tsconfig.stories.json", "test": "yarn run jest", - "test:cypress": "yarn run cypress --record", "type-check": "tsc --project tsconfig.lib.json && yarn run storybook:typecheck && yarn run cypress:type-check && yarn run jest:type-check", "jest": "jest", "jest:type-check": "tsc --project tsconfig.jest.json", "jest:watch": "jest --watch", "generate:known-theme-props": "node ./scripts/generate-known-theme-properties.js", "generate:component": "hygen component new", - "cypress": "ELECTRON_EXTRA_LAUNCH_ARGS=--disable-color-correct-rendering percy exec -- cypress run --component --browser=electron", + "cypress:component": "ELECTRON_EXTRA_LAUNCH_ARGS=--disable-color-correct-rendering percy exec -- cypress run --component --browser=electron", + "cypress:e2e": "percy exec -- cypress run --e2e --browser=electron", "cypress:open": "ELECTRON_EXTRA_LAUNCH_ARGS=--disable-color-correct-rendering cypress open --component --browser=electron", - "cypress:type-check": "tsc --project tsconfig.cypress.json", + "cypress:type-check": "tsc --project tsconfig.cypress.json && tsc --project tsconfig.cypress-e2e.json", "api-docs:extract": "yarn api-extractor run -c ./api-extractor.json --local", "parcel": "../../node_modules/.bin/parcel", "api-extractor": "../../node_modules/.bin/api-extractor" @@ -81,6 +81,7 @@ "@babel/core": "^7.13.15", "@babel/plugin-proposal-decorators": "^7.17.12", "@babel/preset-typescript": "7.13.0", + "@oreillymedia/cypress-playback": "^3.0.8", "@percy/cli": "^1.27.1", "@percy/cypress": "^3.1.2", "@react-stately/data": "^3.4.2", @@ -98,6 +99,7 @@ "@types/ramda": "^0.27.44", "@types/react-dom": "^17.0.13", "@types/styled-components": "5.1.9", + "@types/webpack-env": "^1.18.5", "babel-loader": "^8.2.2", "babel-plugin-styled-components": "^1.13.2", "buffer": "^6.0.3", @@ -105,6 +107,8 @@ "crypto-browserify": "^3.12.0", "cypress": "^13.2.0", "cypress-plugin-snapshots": "1.4.4", + "cypress-plugin-steps": "^1.1.1", + "cypress-plugin-xhr-toggle": "^1.2.1", "cypress-real-events": "1.7.4", "hygen": "^6.2.11", "jest": "^29.0.3", diff --git a/packages/jui/src/ActionSystem/components/ActionButton.tsx b/packages/jui/src/ActionSystem/components/ActionButton.tsx index 5c15b897..7e76d645 100644 --- a/packages/jui/src/ActionSystem/components/ActionButton.tsx +++ b/packages/jui/src/ActionSystem/components/ActionButton.tsx @@ -24,6 +24,7 @@ export const ActionButton = ({ const actionButton = ( { action?.perform(); diff --git a/packages/jui/src/AlertDialog/AlertDialog.cy.tsx b/packages/jui/src/AlertDialog/AlertDialog.cy.tsx index 4db0d166..b385734c 100644 --- a/packages/jui/src/AlertDialog/AlertDialog.cy.tsx +++ b/packages/jui/src/AlertDialog/AlertDialog.cy.tsx @@ -82,7 +82,7 @@ describe("AlertDialog", () => { function matchImageSnapshot(snapshotsName: string) { // with percy - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); // or local snapshot testing diff --git a/packages/jui/src/Balloon/Balloon.cy.tsx b/packages/jui/src/Balloon/Balloon.cy.tsx index 829ce87b..89b015c4 100644 --- a/packages/jui/src/Balloon/Balloon.cy.tsx +++ b/packages/jui/src/Balloon/Balloon.cy.tsx @@ -153,7 +153,7 @@ describe("Balloon", () => { function matchImageSnapshot(snapshotsName: string) { // with percy - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); // or local snapshot testing diff --git a/packages/jui/src/Balloon/BalloonManager.cy.tsx b/packages/jui/src/Balloon/BalloonManager.cy.tsx index b44a340d..1224c454 100644 --- a/packages/jui/src/Balloon/BalloonManager.cy.tsx +++ b/packages/jui/src/Balloon/BalloonManager.cy.tsx @@ -43,7 +43,7 @@ describe("Balloon", () => { function matchImageSnapshot(snapshotsName: string) { // with percy - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); // or local snapshot testing diff --git a/packages/jui/src/Button/Button.cy.tsx b/packages/jui/src/Button/Button.cy.tsx index 2315b45e..9010ead8 100644 --- a/packages/jui/src/Button/Button.cy.tsx +++ b/packages/jui/src/Button/Button.cy.tsx @@ -108,6 +108,6 @@ describe("Button", () => { function matchImageSnapshot(snapshotsName: string) { // NOTE: right now focus state is lost in percy snapshots. Seems like an issue in percy at the moment, since the // element is properly focused before and after percy snapshot. - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); } diff --git a/packages/jui/src/ButtonGroup/ButtonGroup.cy.tsx b/packages/jui/src/ButtonGroup/ButtonGroup.cy.tsx index 47097aa8..338f89ed 100644 --- a/packages/jui/src/ButtonGroup/ButtonGroup.cy.tsx +++ b/packages/jui/src/ButtonGroup/ButtonGroup.cy.tsx @@ -34,7 +34,7 @@ describe("ButtonGroup", () => { function matchImageSnapshot(snapshotsName: string) { // with percy - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); // or local snapshot testing diff --git a/packages/jui/src/Checkbox/Checkbox.cy.tsx b/packages/jui/src/Checkbox/Checkbox.cy.tsx index 8816e4a1..8e9a6aeb 100644 --- a/packages/jui/src/Checkbox/Checkbox.cy.tsx +++ b/packages/jui/src/Checkbox/Checkbox.cy.tsx @@ -116,6 +116,6 @@ describe("Checkbox", () => { }); function matchImageSnapshot(snapshotsName: string) { - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); } diff --git a/packages/jui/src/Icon/useSvgIcon.tsx b/packages/jui/src/Icon/useSvgIcon.tsx index f0c6ed4c..d5a743a5 100644 --- a/packages/jui/src/Icon/useSvgIcon.tsx +++ b/packages/jui/src/Icon/useSvgIcon.tsx @@ -24,7 +24,7 @@ export function useSvgIcon( } if (ref.current) { // For querying for icons that are not loaded yet. Especially useful for visual testing - ref.current.dataset.loadingIcon = "true"; + ref.current.ariaBusy = "true"; } const svg = await theme.getSvgIcon(path, selected).catch((e) => { if (fallbackPath) { @@ -33,14 +33,13 @@ export function useSvgIcon( throw e; }); if (svg) { - if (!unmounted && ref?.current) { - if (ref) { - ref.current?.querySelector("svg")?.remove(); - const svgElement = document.createElement("svg"); - ref.current?.appendChild(svgElement); - svgElement.outerHTML = makeIdsUnique(svg); // UNSAFE! Would require sanitization, or icon sources must be trusted. - delete ref.current?.dataset.loadingIcon; - } + const element = ref?.current; + if (!unmounted && element) { + element.querySelector("svg")?.remove(); + const svgElement = document.createElement("svg"); + element.appendChild(svgElement); + svgElement.outerHTML = makeIdsUnique(svg); // UNSAFE! Would require sanitization, or icon sources must be trusted. + element.ariaBusy = "false"; } } else { console.error("Could not resolve icon:", path); diff --git a/packages/jui/src/InputField/Input.cy.tsx b/packages/jui/src/InputField/Input.cy.tsx index 3f202290..9bdd62ea 100644 --- a/packages/jui/src/InputField/Input.cy.tsx +++ b/packages/jui/src/InputField/Input.cy.tsx @@ -29,7 +29,7 @@ describe("Input", () => { } /> ); - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.findByRole("button").realMouseDown(); cy.findByRole("textbox").should("be.focused"); diff --git a/packages/jui/src/InputField/InputField.cy.tsx b/packages/jui/src/InputField/InputField.cy.tsx index 652f35be..f8cf890c 100644 --- a/packages/jui/src/InputField/InputField.cy.tsx +++ b/packages/jui/src/InputField/InputField.cy.tsx @@ -81,7 +81,7 @@ describe("InputField", () => { function matchImageSnapshot(snapshotsName: string) { // with percy - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); // or local snapshot testing diff --git a/packages/jui/src/Link/Link.cy.tsx b/packages/jui/src/Link/Link.cy.tsx index aa25f71c..2e699d7b 100644 --- a/packages/jui/src/Link/Link.cy.tsx +++ b/packages/jui/src/Link/Link.cy.tsx @@ -36,7 +36,7 @@ describe("Link", () => { function matchImageSnapshot(snapshotsName: string) { // with percy - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); // or local snapshot testing diff --git a/packages/jui/src/List/List.tsx b/packages/jui/src/List/List.tsx index c1a2b0a5..411de626 100644 --- a/packages/jui/src/List/List.tsx +++ b/packages/jui/src/List/List.tsx @@ -5,7 +5,7 @@ import { useList } from "./useList"; import { ListItem } from "./ListItem"; import { StyledList } from "./StyledList"; import { useListState } from "./useListState"; -import { useObjectRef } from "@react-aria/utils"; +import { filterDOMProps, useObjectRef } from "@react-aria/utils"; import { CollectionRefProps } from "@intellij-platform/core/Collections/useCollectionRef"; import { Virtualizer } from "@react-aria/virtualizer"; @@ -87,6 +87,7 @@ export const List = React.forwardRef(function List( as={Virtualizer, any>} {...virtualizerProps} {...listProps} + {...filterDOMProps(props, { labelable: true })} fillAvailableSpace={fillAvailableSpace} className={className} ref={ref} diff --git a/packages/jui/src/List/ListItem.tsx b/packages/jui/src/List/ListItem.tsx index 024987b5..2215c38f 100644 --- a/packages/jui/src/List/ListItem.tsx +++ b/packages/jui/src/List/ListItem.tsx @@ -36,6 +36,7 @@ export function ListItem({ item, children }: ListItemProps) { disabled={isDisabled} aria-disabled={isDisabled} aria-selected={isSelected} + aria-label={item["aria-label"]} {...pressProps} ref={ref} > diff --git a/packages/jui/src/List/SpeedSearchList/SpeedSearchList.tsx b/packages/jui/src/List/SpeedSearchList/SpeedSearchList.tsx index abf764ff..1676033a 100644 --- a/packages/jui/src/List/SpeedSearchList/SpeedSearchList.tsx +++ b/packages/jui/src/List/SpeedSearchList/SpeedSearchList.tsx @@ -1,6 +1,6 @@ import React, { ForwardedRef } from "react"; import { AriaListBoxProps } from "@react-types/listbox"; -import { useObjectRef } from "@react-aria/utils"; +import { filterDOMProps, useObjectRef } from "@react-aria/utils"; import { Virtualizer } from "@react-aria/virtualizer"; import { Node } from "@react-types/shared"; @@ -73,6 +73,7 @@ export const SpeedSearchList = React.forwardRef(function SpeedSearchList< as={Virtualizer, any>} ref={ref} fillAvailableSpace={fillAvailableSpace} + {...filterDOMProps(props, { labelable: true })} {...virtualizerProps} {...listProps} > diff --git a/packages/jui/src/Menu/Menu.cy.tsx b/packages/jui/src/Menu/Menu.cy.tsx index 16428b12..bea5a3f5 100644 --- a/packages/jui/src/Menu/Menu.cy.tsx +++ b/packages/jui/src/Menu/Menu.cy.tsx @@ -801,6 +801,6 @@ describe("ContextMenu", () => { }); function matchImageSnapshot(snapshotsName: string) { - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); } diff --git a/packages/jui/src/Menu/SpeedSearchMenu.cy.tsx b/packages/jui/src/Menu/SpeedSearchMenu.cy.tsx index fcb1198a..4aaec348 100644 --- a/packages/jui/src/Menu/SpeedSearchMenu.cy.tsx +++ b/packages/jui/src/Menu/SpeedSearchMenu.cy.tsx @@ -169,6 +169,6 @@ describe("SpeedSearchMenu", () => { }); function matchImageSnapshot(snapshotsName: string) { - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); } diff --git a/packages/jui/src/Mnemonic/MnemonicTrigger.cy.tsx b/packages/jui/src/Mnemonic/MnemonicTrigger.cy.tsx index fcdb0946..96be8965 100644 --- a/packages/jui/src/Mnemonic/MnemonicTrigger.cy.tsx +++ b/packages/jui/src/Mnemonic/MnemonicTrigger.cy.tsx @@ -120,7 +120,7 @@ describe("Mnemonic", () => { function matchImageSnapshot(snapshotsName: string) { // with percy - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); // or local snapshot testing diff --git a/packages/jui/src/ModalWindow/ModalWindow.cy.tsx b/packages/jui/src/ModalWindow/ModalWindow.cy.tsx index 072bf3ea..be66793b 100644 --- a/packages/jui/src/ModalWindow/ModalWindow.cy.tsx +++ b/packages/jui/src/ModalWindow/ModalWindow.cy.tsx @@ -130,6 +130,6 @@ function drag(from: { x: number; y: number }, to: { x: number; y: number }) { } function matchImageSnapshot(snapshotsName: string) { - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); } diff --git a/packages/jui/src/Popup/Popup.cy.tsx b/packages/jui/src/Popup/Popup.cy.tsx index bbb312fe..839e32c7 100644 --- a/packages/jui/src/Popup/Popup.cy.tsx +++ b/packages/jui/src/Popup/Popup.cy.tsx @@ -326,7 +326,7 @@ describe("Popup", () => { function matchImageSnapshot(snapshotsName: string) { // with percy - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); // or local snapshot testing diff --git a/packages/jui/src/ProgressBar/ProgressBar.cy.tsx b/packages/jui/src/ProgressBar/ProgressBar.cy.tsx index 789fb6df..cd501a9c 100644 --- a/packages/jui/src/ProgressBar/ProgressBar.cy.tsx +++ b/packages/jui/src/ProgressBar/ProgressBar.cy.tsx @@ -25,7 +25,7 @@ describe("ProgressBar", () => { function matchImageSnapshot(snapshotsName: string) { // with percy - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); // or local snapshot testing diff --git a/packages/jui/src/SearchInput/SearchInput.cy.tsx b/packages/jui/src/SearchInput/SearchInput.cy.tsx index 386f0c85..f379d570 100644 --- a/packages/jui/src/SearchInput/SearchInput.cy.tsx +++ b/packages/jui/src/SearchInput/SearchInput.cy.tsx @@ -136,7 +136,7 @@ function workaroundHoverIssue() { function matchImageSnapshot(snapshotsName: string) { // with percy - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); // or local snapshot testing diff --git a/packages/jui/src/StatusBar/StatusBar.cy.tsx b/packages/jui/src/StatusBar/StatusBar.cy.tsx index cfd5f3c7..a95342ef 100644 --- a/packages/jui/src/StatusBar/StatusBar.cy.tsx +++ b/packages/jui/src/StatusBar/StatusBar.cy.tsx @@ -30,7 +30,7 @@ describe("StatusBar", () => { function matchImageSnapshot(snapshotsName: string) { // with percy - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); // or local snapshot testing diff --git a/packages/jui/src/Tabs/Tabs.cy.tsx b/packages/jui/src/Tabs/Tabs.cy.tsx index e68e9e52..7da502d3 100644 --- a/packages/jui/src/Tabs/Tabs.cy.tsx +++ b/packages/jui/src/Tabs/Tabs.cy.tsx @@ -47,7 +47,7 @@ describe("Tabs", () => { // Local visual testing turned out problematic. switching to percy, at least for this test case const compareSnapshot = (name: string) => { - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(name); }; @@ -76,7 +76,7 @@ describe("Tabs", () => { }); function matchImageSnapshot(snapshotsName: string) { - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.document().toMatchImageSnapshot({ name: snapshotsName, // imageConfig: { diff --git a/packages/jui/src/Toolbar/Toolbar.cy.tsx b/packages/jui/src/Toolbar/Toolbar.cy.tsx index e1b31622..22739bc1 100644 --- a/packages/jui/src/Toolbar/Toolbar.cy.tsx +++ b/packages/jui/src/Toolbar/Toolbar.cy.tsx @@ -240,7 +240,7 @@ function checkOverflowPopupIsHidden(buttonNameToCheckBasedOn = "Expand All") { function matchImageSnapshot(snapshotsName: string) { // with percy - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); // or local snapshot testing diff --git a/packages/jui/src/Tooltip/Tooltip.cy.tsx b/packages/jui/src/Tooltip/Tooltip.cy.tsx index c67dc0bc..873e15ae 100644 --- a/packages/jui/src/Tooltip/Tooltip.cy.tsx +++ b/packages/jui/src/Tooltip/Tooltip.cy.tsx @@ -280,7 +280,7 @@ describe("Tooltip", () => { function matchImageSnapshot(snapshotsName: string) { // with percy - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); // or local snapshot testing diff --git a/packages/jui/src/Tree/SpeedSearchTree/SpeedSearchTree.cy.tsx b/packages/jui/src/Tree/SpeedSearchTree/SpeedSearchTree.cy.tsx index 8836bb40..da1e272a 100644 --- a/packages/jui/src/Tree/SpeedSearchTree/SpeedSearchTree.cy.tsx +++ b/packages/jui/src/Tree/SpeedSearchTree/SpeedSearchTree.cy.tsx @@ -74,7 +74,7 @@ describe("SpeedSearchTree", () => { }); function matchImageSnapshot(snapshotsName: string) { - cy.get("[data-loading-icon]", { timeout: 10000 }).should("not.exist"); + cy.get("[aria-busy=true]", { timeout: 10000 }).should("not.exist"); cy.get("#component-container").toMatchImageSnapshot({ name: snapshotsName, screenshotConfig: { padding: [25, 0] }, diff --git a/packages/jui/src/Tree/SpeedSearchTree/SpeedSearchTree.tsx b/packages/jui/src/Tree/SpeedSearchTree/SpeedSearchTree.tsx index e90129f4..03cb4b30 100644 --- a/packages/jui/src/Tree/SpeedSearchTree/SpeedSearchTree.tsx +++ b/packages/jui/src/Tree/SpeedSearchTree/SpeedSearchTree.tsx @@ -16,6 +16,7 @@ import { useTreeVirtualizer } from "../useTreeVirtualizer"; import { TreeContext } from "../TreeContext"; import { useSpeedSearchTree } from "./useSpeedSearchTree"; import { SpeedSearchTreeNode } from "./SpeedSearchTreeNode"; +import { filterDOMProps } from "@react-aria/utils"; export type SpeedSearchTreeProps = TreeProps & SpeedSearchProps & @@ -66,6 +67,7 @@ export const SpeedSearchTree = React.forwardRef( fillAvailableSpace={fillAvailableSpace} {...virtualizerProps} {...treeProps} + {...filterDOMProps(props, { labelable: true })} style={style} className={className} > diff --git a/packages/jui/src/Tree/Tree.cy.tsx b/packages/jui/src/Tree/Tree.cy.tsx index b813462c..238f6706 100644 --- a/packages/jui/src/Tree/Tree.cy.tsx +++ b/packages/jui/src/Tree/Tree.cy.tsx @@ -242,6 +242,6 @@ const beHorizontallyScrollable = ($el: Cypress.JQueryWithSelector) => { }; function matchImageSnapshot(snapshotsName: string) { - cy.get("[data-loading-icon]").should("not.exist"); + cy.get("[aria-busy=true]").should("not.exist"); cy.percySnapshot(snapshotsName); } diff --git a/packages/jui/src/Tree/Tree.tsx b/packages/jui/src/Tree/Tree.tsx index 9b1ae6b6..72f06e19 100644 --- a/packages/jui/src/Tree/Tree.tsx +++ b/packages/jui/src/Tree/Tree.tsx @@ -14,7 +14,7 @@ import { CollectionRefProps, useCollectionRef, } from "@intellij-platform/core/Collections/useCollectionRef"; -import { useObjectRef } from "@react-aria/utils"; +import { filterDOMProps, useObjectRef } from "@react-aria/utils"; export interface TreeProps extends Omit, "disallowEmptySelection">, @@ -74,6 +74,7 @@ export const Tree = React.forwardRef( fillAvailableSpace={fillAvailableSpace} {...virtualizerProps} {...treeProps} + {...filterDOMProps(props, { labelable: true })} style={style} className={className} > diff --git a/packages/jui/src/theme.cy.tsx b/packages/jui/src/theme.cy.tsx index d3f4b6eb..b56c136b 100644 --- a/packages/jui/src/theme.cy.tsx +++ b/packages/jui/src/theme.cy.tsx @@ -32,6 +32,6 @@ function testTheme(theme: Theme) { } function matchImageSnapshot(snapshotsName: string) { - cy.get("[data-loading-icon]", { timeout: 10000 }).should("not.exist"); + cy.get("[aria-busy=true]", { timeout: 10000 }).should("not.exist"); cy.percySnapshot(snapshotsName); } diff --git a/packages/jui/tsconfig.cypress-e2e.json b/packages/jui/tsconfig.cypress-e2e.json new file mode 100644 index 00000000..bcbc6883 --- /dev/null +++ b/packages/jui/tsconfig.cypress-e2e.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.cypress.json", + "include": [ + "cypress/e2e/**/*.ts", + "src/ForwardRefPatch.d.ts", + "cypress/cypress-plugin-snapshots-types.d.ts", + "cypress/support/**/*.ts" + ], + "exclude": ["cypress/support/component.tsx"] +} diff --git a/packages/jui/tsconfig.cypress.json b/packages/jui/tsconfig.cypress.json index 4a652f92..ff0540e9 100644 --- a/packages/jui/tsconfig.cypress.json +++ b/packages/jui/tsconfig.cypress.json @@ -5,6 +5,7 @@ "node", "cypress", "cypress-real-events", + "cypress-plugin-steps", "@percy/cypress", "@testing-library/cypress" ] @@ -13,7 +14,6 @@ "**/*.cy.tsx", "src/ForwardRefPatch.d.ts", "cypress/cypress-plugin-snapshots-types.d.ts", - "cypress/cypress.d.ts", - "cypress/support/*.ts" + "cypress/support/component.tsx" ] } diff --git a/packages/jui/tsconfig.json b/packages/jui/tsconfig.json index 92caae7c..ba38473d 100644 --- a/packages/jui/tsconfig.json +++ b/packages/jui/tsconfig.json @@ -5,6 +5,9 @@ { "path": "tsconfig.cypress.json" }, + { + "path": "tsconfig.cypress-e2e.json" + }, { "path": "tsconfig.stories.json" }, diff --git a/yarn.lock b/yarn.lock index e1578f06..07a43ace 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4064,6 +4064,7 @@ __metadata: "@babel/core": ^7.13.15 "@babel/plugin-proposal-decorators": ^7.17.12 "@babel/preset-typescript": 7.13.0 + "@oreillymedia/cypress-playback": ^3.0.8 "@percy/cli": ^1.27.1 "@percy/cypress": ^3.1.2 "@react-aria/button": ^3.4.1 @@ -4115,6 +4116,7 @@ __metadata: "@types/ramda": ^0.27.44 "@types/react-dom": ^17.0.13 "@types/styled-components": 5.1.9 + "@types/webpack-env": ^1.18.5 babel-loader: ^8.2.2 babel-plugin-styled-components: ^1.13.2 buffer: ^6.0.3 @@ -4122,6 +4124,8 @@ __metadata: crypto-browserify: ^3.12.0 cypress: ^13.2.0 cypress-plugin-snapshots: 1.4.4 + cypress-plugin-steps: ^1.1.1 + cypress-plugin-xhr-toggle: ^1.2.1 cypress-real-events: 1.7.4 hygen: ^6.2.11 jest: ^29.0.3 @@ -5620,6 +5624,19 @@ __metadata: languageName: node linkType: hard +"@oreillymedia/cypress-playback@npm:^3.0.8": + version: 3.0.8 + resolution: "@oreillymedia/cypress-playback@npm:3.0.8" + dependencies: + blueimp-md5: 2.19.0 + lodash.kebabcase: 4.1.1 + node-fetch: 3.2.10 + peerDependencies: + cypress: ">=10" + checksum: 664271d44c174d1db8fac40b33fd802631bd4035c84a8cafa4472bc01111aa281630877783afb0ca5816a177cf7f9bfa66d59279c4d29cb5e43ec74a16b3f8d5 + languageName: node + linkType: hard + "@parcel/bundler-default@npm:2.8.3": version: 2.8.3 resolution: "@parcel/bundler-default@npm:2.8.3" @@ -10265,6 +10282,13 @@ __metadata: languageName: node linkType: hard +"@types/webpack-env@npm:^1.18.5": + version: 1.18.5 + resolution: "@types/webpack-env@npm:1.18.5" + checksum: 4ca8eb4c44e1e1807c3e245442fce7aaf2816a163056de9436bbac44cc47c8bc5b1c9a330dc05748d6616431b1fb5bd5379733fb1da0b78d03c59f4ec824c184 + languageName: node + linkType: hard + "@types/ws@npm:^8.5.1": version: 8.5.3 resolution: "@types/ws@npm:8.5.3" @@ -11898,6 +11922,13 @@ __metadata: languageName: node linkType: hard +"blueimp-md5@npm:2.19.0": + version: 2.19.0 + resolution: "blueimp-md5@npm:2.19.0" + checksum: 28095dcbd2c67152a2938006e8d7c74c3406ba6556071298f872505432feb2c13241b0476644160ee0a5220383ba94cb8ccdac0053b51f68d168728f9c382530 + languageName: node + linkType: hard + "bmp-js@npm:^0.1.0": version: 0.1.0 resolution: "bmp-js@npm:0.1.0" @@ -13692,6 +13723,24 @@ __metadata: languageName: node linkType: hard +"cypress-plugin-steps@npm:^1.1.1": + version: 1.1.1 + resolution: "cypress-plugin-steps@npm:1.1.1" + peerDependencies: + cypress: ">=10" + checksum: e62c8322cff3fc0c03f4cf978d623c86eed0964a7eb236d632cce1a8e1a715f640d1cc765dade7a6024c300ee893035f42262a672e01c8d5ab6dd6709a6eff4b + languageName: node + linkType: hard + +"cypress-plugin-xhr-toggle@npm:^1.2.1": + version: 1.2.1 + resolution: "cypress-plugin-xhr-toggle@npm:1.2.1" + peerDependencies: + cypress: ">=10" + checksum: e53aac48689b7051150a516b6f7346cef8551ad352a7550347160a037fa473d5930cf02268d0c43e0d5009504bba00ff52e6bcd76986ac1889b963b414e68134 + languageName: node + linkType: hard + "cypress-real-events@npm:1.7.4": version: 1.7.4 resolution: "cypress-real-events@npm:1.7.4" @@ -13763,6 +13812,13 @@ __metadata: languageName: node linkType: hard +"data-uri-to-buffer@npm:^4.0.0": + version: 4.0.1 + resolution: "data-uri-to-buffer@npm:4.0.1" + checksum: 0d0790b67ffec5302f204c2ccca4494f70b4e2d940fea3d36b09f0bb2b8539c2e86690429eb1f1dc4bcc9e4df0644193073e63d9ee48ac9fce79ec1506e4aa4c + languageName: node + linkType: hard + "dayjs@npm:^1.10.4": version: 1.10.7 resolution: "dayjs@npm:1.10.7" @@ -15659,6 +15715,16 @@ __metadata: languageName: node linkType: hard +"fetch-blob@npm:^3.1.2, fetch-blob@npm:^3.1.4": + version: 3.2.0 + resolution: "fetch-blob@npm:3.2.0" + dependencies: + node-domexception: ^1.0.0 + web-streams-polyfill: ^3.0.3 + checksum: f19bc28a2a0b9626e69fd7cf3a05798706db7f6c7548da657cbf5026a570945f5eeaedff52007ea35c8bcd3d237c58a20bf1543bc568ab2422411d762dd3d5bf + languageName: node + linkType: hard + "fetch-retry@npm:^5.0.2": version: 5.0.6 resolution: "fetch-retry@npm:5.0.6" @@ -15991,6 +16057,15 @@ __metadata: languageName: node linkType: hard +"formdata-polyfill@npm:^4.0.10": + version: 4.0.10 + resolution: "formdata-polyfill@npm:4.0.10" + dependencies: + fetch-blob: ^3.1.2 + checksum: 82a34df292afadd82b43d4a740ce387bc08541e0a534358425193017bf9fb3567875dc5f69564984b1da979979b70703aa73dee715a17b6c229752ae736dd9db + languageName: node + linkType: hard + "forwarded@npm:0.2.0": version: 0.2.0 resolution: "forwarded@npm:0.2.0" @@ -19802,6 +19877,13 @@ __metadata: languageName: node linkType: hard +"lodash.kebabcase@npm:4.1.1": + version: 4.1.1 + resolution: "lodash.kebabcase@npm:4.1.1" + checksum: 5a6c59161914e1bae23438a298c7433e83d935e0f59853fa862e691164696bc07f6dfa4c313d499fbf41ba8d53314e9850416502376705a357d24ee6ca33af78 + languageName: node + linkType: hard + "lodash.memoize@npm:^4.1.2": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" @@ -20715,6 +20797,13 @@ __metadata: languageName: node linkType: hard +"node-domexception@npm:^1.0.0": + version: 1.0.0 + resolution: "node-domexception@npm:1.0.0" + checksum: ee1d37dd2a4eb26a8a92cd6b64dfc29caec72bff5e1ed9aba80c294f57a31ba4895a60fd48347cf17dd6e766da0ae87d75657dfd1f384ebfa60462c2283f5c7f + languageName: node + linkType: hard + "node-emoji@npm:^1.10.0": version: 1.11.0 resolution: "node-emoji@npm:1.11.0" @@ -20738,6 +20827,17 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:3.2.10": + version: 3.2.10 + resolution: "node-fetch@npm:3.2.10" + dependencies: + data-uri-to-buffer: ^4.0.0 + fetch-blob: ^3.1.4 + formdata-polyfill: ^4.0.10 + checksum: e65322431f4897ded04197aa5923eaec63a8d53e00432de4e70a4f7006625c8dc32629c5c35f4fe8ee719a4825544d07bf53f6e146a7265914262f493e8deac1 + languageName: node + linkType: hard + "node-fetch@npm:^2.0.0": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" @@ -26696,6 +26796,13 @@ __metadata: languageName: node linkType: hard +"web-streams-polyfill@npm:^3.0.3": + version: 3.3.3 + resolution: "web-streams-polyfill@npm:3.3.3" + checksum: 21ab5ea08a730a2ef8023736afe16713b4f2023ec1c7085c16c8e293ee17ed085dff63a0ad8722da30c99c4ccbd4ccd1b2e79c861829f7ef2963d7de7004c2cb + languageName: node + linkType: hard + "webidl-conversions@npm:^3.0.0": version: 3.0.1 resolution: "webidl-conversions@npm:3.0.1"