+ 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..1305e575 100644
--- a/packages/jui/src/ModalWindow/ModalWindow.cy.tsx
+++ b/packages/jui/src/ModalWindow/ModalWindow.cy.tsx
@@ -17,7 +17,10 @@ describe("ModalWindow", () => {
it("it allows for navigating buttons with arrow keys", () => {
cy.mount();
- cy.findByRole("button", { name: "Ok" }).focus().realPress("ArrowLeft");
+ cy.findByRole("button", { name: "Ok" })
+ .focus()
+ .should("be.focused")
+ .realPress("ArrowLeft");
cy.findByRole("button", { name: "Cancel" })
.should("be.focused")
.realPress("ArrowLeft"); // should wrap
@@ -130,6 +133,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"