فهرست منبع

Merge branch 'master' into fix/111880-internal-server-err-when-pending-user-login

Kaori Tokashiki 3 سال پیش
والد
کامیت
c21e0496c8

+ 2 - 5
packages/app/src/components/Admin/PluginsExtension/PluginInstallerForm.tsx

@@ -3,9 +3,6 @@ import React, { useCallback } from 'react';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 
-import AdminInstallButtonRow from '../Common/AdminUpdateButtonRow';
-// TODO: error notification (toast, loggerFactory)
-// TODO: i18n
 
 export const PluginInstallerForm = (): JSX.Element => {
   // const { t } = useTranslation('admin');
@@ -31,8 +28,8 @@ export const PluginInstallerForm = (): JSX.Element => {
       await apiv3Post('/plugins', { pluginInstallerForm });
       toastSuccess('Plugin Install Successed!');
     }
-    catch (err) {
-      toastError(err);
+    catch (e) {
+      toastError(e);
     }
   }, []);
 

+ 23 - 18
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -5,6 +5,7 @@ import {
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
 
+import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
 import {
   IPageInfoAll, isIPageInfoForOperation,
 } from '~/interfaces/page';
@@ -331,24 +332,28 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   }, [onClickPathRecoveryMenuItem, pageId]);
 
   return (
-    <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)} data-testid="open-page-item-control-btn">
-      { children ?? (
-        <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
-          <i className="icon-options"></i>
-        </DropdownToggle>
-      ) }
-
-      <PageItemControlDropdownMenu
-        {...props}
-        isLoading={isLoading}
-        pageInfo={fetchedPageInfo ?? presetPageInfo}
-        onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
-        onClickRenameMenuItem={renameMenuItemClickHandler}
-        onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
-        onClickDeleteMenuItem={deleteMenuItemClickHandler}
-        onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
-      />
-    </Dropdown>
+    <NotAvailableForGuest>
+      <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)} data-testid="open-page-item-control-btn">
+        { children ?? (
+          <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
+            <i className="icon-options"></i>
+          </DropdownToggle>
+        ) }
+
+        <PageItemControlDropdownMenu
+          {...props}
+          isLoading={isLoading}
+          pageInfo={fetchedPageInfo ?? presetPageInfo}
+          onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+          onClickRenameMenuItem={renameMenuItemClickHandler}
+          onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+          onClickDeleteMenuItem={deleteMenuItemClickHandler}
+          onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
+        />
+      </Dropdown>
+
+    </NotAvailableForGuest>
+
   );
 
 };

+ 6 - 12
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -6,13 +6,12 @@ import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 import { DropdownItem } from 'reactstrap';
 
-import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
+import { exportAsMarkdown, updateContentWidth, useUpdateStateAfterSave } from '~/client/services/page-operation';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import {
   IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
 } from '~/interfaces/page';
-import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
   useCurrentPageId, useCurrentPathname, useIsNotFound,
@@ -221,6 +220,8 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { open: openDeleteModal } = usePageDeleteModal();
   const { data: templateTagData } = useTemplateTagData();
 
+  const updateStateAfterSave = useUpdateStateAfterSave(pageId);
+
   const path = currentPage?.path ?? currentPathname;
 
   useEffect(() => {
@@ -251,16 +252,9 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
     const { _id: pageId, revision: revisionId } = currentPage;
     try {
-      const res: IResTagsUpdateApiv1 = await apiPost('/tags.update', { pageId, revisionId, tags: newTags });
-      mutateCurrentPage();
-
-      // TODO: fix https://github.com/weseek/growi/pull/6478 without pageContainer
-      // const lastUpdateUser = res.savedPage?.lastUpdateUser as IUser;
-      // await pageContainer.setState({ lastUpdateUsername: lastUpdateUser.username });
+      await apiPost('/tags.update', { pageId, revisionId, tags: newTags });
 
-      // revalidate SWRTagsInfo
-      mutateSWRTagsInfo();
-      mutatePageTagsForEditors(newTags);
+      updateStateAfterSave?.();
 
       toastSuccess('updated tags successfully');
     }
@@ -268,7 +262,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       toastError(err, 'fail to update tags');
     }
 
-  }, [currentPage, mutateCurrentPage, mutateSWRTagsInfo, mutatePageTagsForEditors]);
+  }, [currentPage, updateStateAfterSave]);
 
   const tagsUpdatedHandlerForEditMode = useCallback((newTags: string[]): void => {
     // It will not be reflected in the DB until the page is refreshed

+ 15 - 12
packages/app/src/components/PageComment.tsx

@@ -13,6 +13,7 @@ import { useCommentForCurrentPageOptions } from '~/stores/renderer';
 import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
 import { useSWRxPageComment } from '../stores/comment';
 
+import { NotAvailableForGuest } from './NotAvailableForGuest';
 import { Comment } from './PageComment/Comment';
 import { CommentEditor } from './PageComment/CommentEditor';
 import { DeleteCommentModal } from './PageComment/DeleteCommentModal';
@@ -173,18 +174,20 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
                   {commentElement(comment)}
                   {hasReply && replyCommentsElement(allReplies[comment._id])}
                   {(!isReadOnly && !showEditorIds.has(comment._id)) && (
-                    <div className="text-right">
-                      <Button
-                        outline
-                        color="secondary"
-                        size="sm"
-                        className="btn-comment-reply"
-                        onClick={() => {
-                          setShowEditorIds(previousState => new Set(previousState.add(comment._id)));
-                        }}
-                      >
-                        <i className="icon-fw icon-action-undo"></i> Reply
-                      </Button>
+                    <div className="d-flex flex-row-reverse">
+                      <NotAvailableForGuest>
+                        <Button
+                          outline
+                          color="secondary"
+                          size="sm"
+                          className="btn-comment-reply"
+                          onClick={() => {
+                            setShowEditorIds(previousState => new Set(previousState.add(comment._id)));
+                          }}
+                        >
+                          <i className="icon-fw icon-action-undo"></i> Reply
+                        </Button>
+                      </NotAvailableForGuest>
                     </div>
                   )}
                   {(!isReadOnly && showEditorIds.has(comment._id)) && (

+ 2 - 3
packages/app/src/components/PageEditorByHackmd.tsx

@@ -117,8 +117,6 @@ export const PageEditorByHackmd = (): JSX.Element => {
       const markdown = await hackmdEditorRef.current.getValue();
 
       const { page } = await saveOrUpdate(markdown, { pageId, path: currentPagePath || currentPathname, revisionId: revisionIdHackmdSynced }, options);
-      await mutatePageData();
-      await mutateTagsInfo();
 
       if (page == null) {
         return;
@@ -128,6 +126,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       }
       else {
         updateStateAfterSave?.();
+        mutateIsHackmdDraftUpdatingInRealtime(false);
       }
       setIsInitialized(false);
       mutateEditorMode(EditorMode.View);
@@ -137,7 +136,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       toastError(error.message);
     }
   // eslint-disable-next-line max-len
-  }, [editorMode, currentPathname, revision, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, pageId, currentPagePath, mutatePageData, mutateTagsInfo, isNotFound, mutateEditorMode, router, updateStateAfterSave]);
+  }, [editorMode, currentPathname, revision, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, pageId, currentPagePath, isNotFound, mutateEditorMode, router, updateStateAfterSave, mutateIsHackmdDraftUpdatingInRealtime]);
 
   // set handler to save and reload Page
   useEffect(() => {

+ 17 - 14
packages/app/src/components/SearchPage.tsx

@@ -12,6 +12,7 @@ import { IFormattedSearchResult } from '~/interfaces/search';
 import { useIsSearchServiceReachable, useShowPageLimitationL } from '~/stores/context';
 import { ISearchConditions, ISearchConfigurations, useSWRxSearch } from '~/stores/search';
 
+import { NotAvailableForGuest } from './NotAvailableForGuest';
 import PaginationWrapper from './PaginationWrapper';
 import { OperateAllControl } from './SearchPage/OperateAllControl';
 import SearchControl from './SearchPage/SearchControl';
@@ -183,21 +184,23 @@ export const SearchPage = (): JSX.Element => {
     const isDisabled = hitsCount === 0;
 
     return (
-      <OperateAllControl
-        ref={selectAllControlRef}
-        isCheckboxDisabled={isDisabled}
-        onCheckboxChanged={selectAllCheckboxChangedHandler}
-      >
-        <button
-          type="button"
-          className="btn btn-outline-danger text-nowrap border-0 px-2"
-          disabled={isDisabled}
-          onClick={deleteAllButtonClickedHandler}
+      <NotAvailableForGuest>
+        <OperateAllControl
+          ref={selectAllControlRef}
+          isCheckboxDisabled={isDisabled}
+          onCheckboxChanged={selectAllCheckboxChangedHandler}
         >
-          <i className="icon-fw icon-trash"></i>
-          {t('search_result.delete_all_selected_page')}
-        </button>
-      </OperateAllControl>
+          <button
+            type="button"
+            className="btn btn-outline-danger text-nowrap border-0 px-2"
+            disabled={isDisabled}
+            onClick={deleteAllButtonClickedHandler}
+          >
+            <i className="icon-fw icon-trash"></i>
+            {t('search_result.delete_all_selected_page')}
+          </button>
+        </OperateAllControl>
+      </NotAvailableForGuest>
     );
   }, [deleteAllButtonClickedHandler, hitsCount, selectAllCheckboxChangedHandler, t]);
 

+ 3 - 0
packages/app/src/server/crowi/index.js

@@ -702,6 +702,9 @@ Crowi.prototype.setupPluginService = async function() {
   if (this.pluginService == null) {
     this.pluginService = new PluginService(this);
   }
+  // download plugin repositories, if document exists but there is no repository
+  // TODO: Cannot download unless connected to the Internet at setup.
+  await this.pluginService.downloadNotExistPluginRepositories();
 };
 
 Crowi.prototype.setupPageService = async function() {

+ 1 - 1
packages/app/src/server/routes/apiv3/plugins.ts

@@ -12,7 +12,7 @@ module.exports = (crowi: Crowi) => {
 
   router.post('/', async(req: PluginInstallerFormRequest, res: ApiV3Response) => {
     if (pluginService == null) {
-      return res.apiv3Err(400);
+      return res.apiv3Err('\'pluginService\' is not set up', 500);
     }
 
     try {

+ 97 - 35
packages/app/src/server/service/plugin.ts

@@ -27,7 +27,6 @@ const PLUGINS_STATIC_DIR = '/static/plugins'; // configured by express.static
 
 export type GrowiPluginResourceEntries = [installedPath: string, href: string][];
 
-
 function retrievePluginManifest(growiPlugin: GrowiPlugin): ViteManifest {
   const manifestPath = resolveFromRoot(path.join('tmp/plugins', growiPlugin.installedPath, 'dist/manifest.json'));
   const manifestStr: string = readFileSync(manifestPath, 'utf-8');
@@ -38,48 +37,96 @@ export interface IPluginService {
   install(origin: GrowiPluginOrigin): Promise<void>
   retrieveThemeHref(theme: string): Promise<string | undefined>
   retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries>
+  downloadNotExistPluginRepositories(): Promise<void>
 }
 
 export class PluginService implements IPluginService {
 
+  async downloadNotExistPluginRepositories(): Promise<void> {
+    try {
+      // check all growi plugin documents
+      const GrowiPlugin = mongoose.model<GrowiPlugin>('GrowiPlugin');
+      const growiPlugins = await GrowiPlugin.find({});
+      for await (const growiPlugin of growiPlugins) {
+        const pluginPath = path.join(pluginStoringPath, growiPlugin.installedPath);
+        if (fs.existsSync(pluginPath)) {
+          // if exists repository, do nothing
+          continue;
+        }
+        else {
+          // if not exists repository, download latest plugin repository
+          // TODO: imprv Document version and repository version possibly different.
+          const ghUrl = new URL(growiPlugin.origin.url);
+          const ghPathname = ghUrl.pathname;
+          // TODO: Branch names can be specified.
+          const ghBranch = 'main';
+          const match = ghPathname.match(githubReposIdPattern);
+          if (ghUrl.hostname !== 'github.com' || match == null) {
+            throw new Error('The GitHub Repository URL is invalid.');
+          }
+
+          const ghOrganizationName = match[1];
+          const ghReposName = match[2];
+
+          // download github repository to local file system
+          await this.downloadPluginRepository(ghOrganizationName, ghReposName, ghBranch);
+          continue;
+        }
+      }
+    }
+    catch (err) {
+      logger.error(err);
+    }
+  }
+
   async install(origin: GrowiPluginOrigin): Promise<void> {
+    try {
     // download
-    const ghUrl = new URL(origin.url);
-    const ghPathname = ghUrl.pathname;
-    // TODO: Branch names can be specified.
-    const ghBranch = 'main';
-
-    const match = ghPathname.match(githubReposIdPattern);
-    if (ghUrl.hostname !== 'github.com' || match == null) {
-      throw new Error('The GitHub Repository URL is invalid.');
-    }
+      const ghUrl = new URL(origin.url);
+      const ghPathname = ghUrl.pathname;
+      // TODO: Branch names can be specified.
+      const ghBranch = 'main';
+
+      const match = ghPathname.match(githubReposIdPattern);
+      if (ghUrl.hostname !== 'github.com' || match == null) {
+        throw new Error('The GitHub Repository URL is invalid.');
+      }
 
-    const ghOrganizationName = match[1];
-    const ghReposName = match[2];
-    const requestUrl = `https://github.com/${ghOrganizationName}/${ghReposName}/archive/refs/heads/${ghBranch}.zip`;
+      const ghOrganizationName = match[1];
+      const ghReposName = match[2];
+      const installedPath = `${ghOrganizationName}/${ghReposName}`;
 
-    // download github repository to local file system
-    await this.download(requestUrl, ghOrganizationName, ghReposName, ghBranch);
+      // download github repository to local file system
+      await this.downloadPluginRepository(ghOrganizationName, ghReposName, ghBranch);
 
-    // save plugin metadata
-    const installedPath = `${ghOrganizationName}/${ghReposName}`;
-    const plugins = await PluginService.detectPlugins(origin, installedPath);
-    await this.savePluginMetaData(plugins);
+      // delete old document
+      await this.deleteOldPluginDocument(installedPath);
+
+      // save plugin metadata
+      const plugins = await PluginService.detectPlugins(origin, installedPath);
+      await this.savePluginMetaData(plugins);
+    }
+    catch (err) {
+      logger.error(err);
+      throw err;
+    }
 
     return;
   }
 
-  private async download(requestUrl: string, ghOrganizationName: string, ghReposName: string, ghBranch: string): Promise<void> {
+  private async deleteOldPluginDocument(path: string): Promise<void> {
+    const GrowiPlugin = mongoose.model<GrowiPlugin>('GrowiPlugin');
+    await GrowiPlugin.deleteMany({ installedPath: path });
+  }
+
+  private async downloadPluginRepository(ghOrganizationName: string, ghReposName: string, ghBranch: string): Promise<void> {
 
+    const requestUrl = `https://github.com/${ghOrganizationName}/${ghReposName}/archive/refs/heads/${ghBranch}.zip`;
     const zipFilePath = path.join(pluginStoringPath, `${ghBranch}.zip`);
     const unzippedPath = path.join(pluginStoringPath, ghOrganizationName);
 
-    const renamePath = async(oldPath: fs.PathLike, newPath: fs.PathLike) => {
-      fs.renameSync(oldPath, newPath);
-    };
-
     const downloadFile = async(requestUrl: string, filePath: string) => {
-      return new Promise<void>((resolve, reject) => {
+      return new Promise<void>((resolve, rejects) => {
         axios({
           method: 'GET',
           url: requestUrl,
@@ -95,25 +142,41 @@ export class PluginService implements IPluginService {
                 });
             }
             else {
-              return reject(res.status);
+              rejects(res.status);
             }
-          }).catch((err) => {
-            return reject(err);
+          }).catch((e) => {
+            logger.error(e);
+            // eslint-disable-next-line prefer-promise-reject-errors
+            rejects('Filed to download file.');
           });
       });
     };
 
     const unzip = async(zipFilePath: fs.PathLike, unzippedPath: fs.PathLike) => {
-      const stream = fs.createReadStream(zipFilePath);
-      const unzipStream = stream.pipe(unzipper.Extract({ path: unzippedPath }));
-      const deleteZipFile = (path: fs.PathLike) => fs.unlink(path, (err) => { return err });
-
       try {
+        const stream = fs.createReadStream(zipFilePath);
+        const unzipStream = stream.pipe(unzipper.Extract({ path: unzippedPath }));
+        const deleteZipFile = (path: fs.PathLike) => fs.unlinkSync(path);
+
         await streamToPromise(unzipStream);
         deleteZipFile(zipFilePath);
       }
       catch (err) {
-        return err;
+        logger.error(err);
+        throw new Error('Filed to unzip.');
+      }
+    };
+
+    const renamePath = async(oldPath: fs.PathLike, newPath: fs.PathLike) => {
+      try {
+        // if repository already exists, delete old repository before rename path
+        if (fs.existsSync(newPath)) await fs.promises.rm(newPath, { recursive: true });
+        // rename repository
+        fs.renameSync(oldPath, newPath);
+      }
+      catch (err) {
+        logger.error(err);
+        throw new Error('Filed to rename path.');
       }
     };
 
@@ -123,8 +186,7 @@ export class PluginService implements IPluginService {
       await renamePath(`${unzippedPath}/${ghReposName}-${ghBranch}`, `${unzippedPath}/${ghReposName}`);
     }
     catch (err) {
-      logger.error(err);
-      throw new Error(err);
+      throw err;
     }
 
     return;

+ 18 - 23
packages/app/test/cypress/integration/20-basic-features/20-basic-features--access-to-page.spec.ts

@@ -20,23 +20,23 @@ context('Access to page', () => {
   });
 
   // TODO: https://redmine.weseek.co.jp/issues/109939
-  // it('/Sandbox with anchor hash is successfully loaded', () => {
-  //   cy.visit('/Sandbox#Headers');
-  //   cy.waitUntilSkeletonDisappear();
+  it('/Sandbox with anchor hash is successfully loaded', () => {
+    cy.visit('/Sandbox#Headers');
+    cy.waitUntilSkeletonDisappear();
 
-  //   // for check download toc data
-  //   cy.get('.toc-link').should('be.visible');
+    // for check download toc data
+    cy.get('.toc-link').should('be.visible');
 
-  //   // hide fab // disable fab for sticky-events warning
-  //   // cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
+    // hide fab // disable fab for sticky-events warning
+    // cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
 
-  //   // remove animation for screenshot
-  //   // remove 'blink' class because ::after element cannot be operated
-  //   // https://stackoverflow.com/questions/5041494/selecting-and-manipulating-css-pseudo-elements-such-as-before-and-after-usin/21709814#21709814
-  //   cy.get('#mdcont-headers').invoke('removeClass', 'blink');
+    // remove animation for screenshot
+    // remove 'blink' class because ::after element cannot be operated
+    // https://stackoverflow.com/questions/5041494/selecting-and-manipulating-css-pseudo-elements-such-as-before-and-after-usin/21709814#21709814
+    cy.get('#mdcont-headers').invoke('removeClass', 'blink');
 
-  //   cy.screenshot(`${ssPrefix}-sandbox-headers`);
-  // });
+    cy.screenshot(`${ssPrefix}-sandbox-headers`);
+  });
 
   it('/Sandbox/Math is successfully loaded', () => {
     cy.visit('/Sandbox/Math');
@@ -45,26 +45,21 @@ context('Access to page', () => {
     // for check download toc data
     cy.get('.toc-link').should('be.visible');
 
-    cy.collapseSidebar(true);
+    cy.get('.math').should('be.visible');
+
     cy.screenshot(`${ssPrefix}-sandbox-math`);
   });
 
   it('/Sandbox with edit is successfully loaded', () => {
-    cy.visit('/Sandbox');
+    cy.visit('/Sandbox#edit');
     cy.waitUntilSkeletonDisappear();
 
-    cy.get('#grw-subnav-container').should('be.visible').within(() => {
-
-      // eslint-disable-next-line cypress/no-unnecessary-waiting
-      cy.wait(2000);
-      cy.getByTestid('editor-button').should('be.visible').click();
-    })
-
     cy.getByTestid('navbar-editor').should('be.visible');
     cy.get('.grw-editor-navbar-bottom').should('be.visible');
     cy.getByTestid('save-page-btn').should('be.visible');
-    cy.get('.grw-grant-selector').should('be.visible')
+    cy.get('.grw-grant-selector').should('be.visible');
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-Sandbox-edit-page`);
   })
 

+ 7 - 6
packages/app/test/cypress/integration/20-basic-features/20-basic-features--use-tools.spec.ts

@@ -210,16 +210,17 @@ context('Page Accessories Modal', () => {
       cy.login(user.username, user.password);
     });
 
-    cy.visit('/Sandbox/Bootstrap4');
+    cy.visit('/');
 
-    cy.getByTestid('grw-contextual-sub-nav').as('subnav').should('be.visible').within(() => {
-      cy.getByTestid('open-page-item-control-btn').as('pageItemControlBtn').should('be.visible');
-    });
     cy.waitUntil(() => {
       // do
-      cy.get('@pageItemControlBtn').click();
+      cy.getByTestid('grw-contextual-sub-nav').should('be.visible').within(() => {
+        cy.getByTestid('open-page-item-control-btn').find('button').first().as('btn').click();
+      });
       // wait until
-      return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
+      return cy.get('body').within(() => {
+        return Cypress.$('.dropdown-menu.show').is(':visible');
+      });
     });
 
   });

+ 16 - 14
packages/app/test/cypress/integration/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.spec.ts

@@ -3,45 +3,47 @@ context('Access to page by guest', () => {
 
   it('/Sandbox is successfully loaded', () => {
     cy.visit('/Sandbox');
-    cy.getByTestid('grw-pagetree-item-container').should('be.visible');
+    cy.waitUntilSkeletonDisappear();
+
     cy.collapseSidebar(true, true);
     cy.screenshot(`${ssPrefix}-sandbox`);
   });
 
+  // TODO: https://redmine.weseek.co.jp/issues/109939
   it('/Sandbox with anchor hash is successfully loaded', () => {
     cy.visit('/Sandbox#Headers');
-    cy.getByTestid('grw-pagetree-item-container').should('be.visible');
-
-    // eslint-disable-next-line cypress/no-unnecessary-waiting
-    // cy.wait(500);
+    cy.waitUntilSkeletonDisappear();
 
     // hide fab // disable fab for sticky-events warning
     // cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
 
-    cy.collapseSidebar(true);
+    // remove animation for screenshot
+    // remove 'blink' class because ::after element cannot be operated
+    // https://stackoverflow.com/questions/5041494/selecting-and-manipulating-css-pseudo-elements-such-as-before-and-after-usin/21709814#21709814
+    cy.get('#mdcont-headers').invoke('removeClass', 'blink');
+
     cy.screenshot(`${ssPrefix}-sandbox-headers`);
   });
 
   it('/Sandbox/Math is successfully loaded', () => {
     cy.visit('/Sandbox/Math');
-    cy.getByTestid('revision-toc-content').should('be.visible');
+    cy.waitUntilSkeletonDisappear();
+
+    // for check download toc data
+    cy.get('.toc-link').should('be.visible');
 
     cy.get('.math').should('be.visible');
 
     cy.collapseSidebar(true);
-    cy.screenshot(`${ssPrefix}-sandbox-math`, {
-      blackout: ['.revision-toc', '[data-hide-in-vrt=true]']
-    });
+    cy.screenshot(`${ssPrefix}-sandbox-math`);
   });
 
   it('/Sandbox with edit is successfully loaded', () => {
     cy.visit('/Sandbox#edit');
-
-    // eslint-disable-next-line cypress/no-unnecessary-waiting
-    cy.wait(1000);
+    cy.waitUntilSkeletonDisappear();
 
     cy.collapseSidebar(true);
-    cy.screenshot(`${ssPrefix}-sandbox-edit-page`);
+    cy.screenshot(`${ssPrefix}-sandbox-with-edit-hash`);
   })
 
 });

+ 17 - 10
packages/app/test/cypress/integration/30-search/30-search--search.spec.ts

@@ -116,15 +116,15 @@ context('Search all pages', () => {
 
     cy.visit('/');
 
-    // Add tag
-    cy.get('#edit-tags-btn-wrapper-for-tooltip').as('edit-tag-tooltip').should('be.visible');
-
-    // open Edit Tags Modal
+    // open Edit Tags Modal to add tag
     cy.waitUntil(() => {
       // do
-      cy.get('@edit-tag-tooltip').find('a').click({force: true});
+      cy.getByTestid('grw-tag-labels').as('tagLabels').should('be.visible');
+      cy.get('@tagLabels').find('a.btn').as('btn').click();
       // wait until
-      return cy.get('#edit-tag-modal').then($elem => $elem.is(':visible'));
+      return cy.get('body').within(() => {
+        return Cypress.$('.modal.show').is(':visible');
+      });
     });
 
     cy.get('#edit-tag-modal').should('be.visible').within(() => {
@@ -168,7 +168,16 @@ context('Search all pages', () => {
     const tag = 'help';
 
     cy.visit('/');
-    cy.get('.grw-taglabels-container > div > a').contains(tag).click();
+
+    // open Edit Tags Modal to add tag
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid('grw-tag-labels').as('tagLabels').should('be.visible');
+      cy.get('@tagLabels').find('a').contains(tag).as('tag').click();
+      // wait until
+      return cy.getByTestid('search-result-base').then($elem => $elem.is(':visible'));
+    });
+
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
@@ -366,12 +375,10 @@ context('Search current tree with "prefix":', () => {
     const searchText = 'help';
 
     cy.visit('/');
-    cy.waitUntilSkeletonDisappear();
-    cy.collapseSidebar(true);
 
     cy.waitUntil(() => {
       // do
-      cy.getByTestid('select-search-scope').click();
+      cy.getByTestid('select-search-scope').should('be.visible').click();
       // wait until
       return cy.get('.grw-global-search-container').within(() => {
         return Cypress.$('.dropdown-menu.show').is(':visible');

+ 5 - 0
packages/app/test/cypress/support/commands.ts

@@ -71,6 +71,11 @@ Cypress.Commands.add('waitUntilSpinnerDisappear', () => {
 
 Cypress.Commands.add('collapseSidebar', (isCollapsed: boolean, waitUntilSaving = false) => {
   cy.getByTestid('grw-sidebar-wrapper', { timeout: 5000 }).within(() => {
+    // skip if .grw-sidebar-dock does not exist
+    if (isHidden(Cypress.$('.grw-sidebar-dock'))) {
+      return;
+    }
+
     // process only when Dock Mode
     cy.get('.grw-sidebar-dock').within(() => {
       const isSidebarContextualNavigationHidden = isHiddenByTestId('grw-contextual-navigation-sub');