diff --git a/README.md b/README.md index d8aa7888..5e5cedf7 100644 --- a/README.md +++ b/README.md @@ -33,4 +33,4 @@ The current code base is geared towards a Janelia deployment, but we are working - [fileglancer-central](https://github.com/JaneliaSciComp/fileglancer-central) - Central server managing access to a shared database and other resources - [fileglancer-hub](https://github.com/JaneliaSciComp/fileglancer-hub) - Deployment of Fileglancer into JupyterHub -- [fileglancer-user-docs](https://github.com/JaneliaSciComp/fileglancer-docs) - User guide +- [fileglancer-docs](https://github.com/JaneliaSciComp/fileglancer-docs) - User guide diff --git a/docs/Development.md b/docs/Development.md index e0f2a0fd..4d325e05 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -96,9 +96,16 @@ pixi run test-frontend This extension uses [Playwright](https://playwright.dev/docs/intro) for the integration tests (aka user level tests). More precisely, the JupyterLab helper [Galata](https://github.com/jupyterlab/jupyterlab/tree/master/galata) is used to handle testing the extension in JupyterLab. -More information are provided within the [ui-tests](../ui-tests/README.md) README. -To execute the UI integration test, run: +To execute the UI integration tests: + +Install test dependencies (needed only once): + +```bash +pixi run npm --prefix ui-tests npx playwright install +``` + +Then run the tests with: ```bash pixi run ui-test diff --git a/fileglancer/handlers.py b/fileglancer/handlers.py index 3f21c99e..2ee325c1 100644 --- a/fileglancer/handlers.py +++ b/fileglancer/handlers.py @@ -2,6 +2,8 @@ import json import requests import re +import grp +import pwd from datetime import datetime, timezone from abc import ABC from mimetypes import guess_type @@ -52,10 +54,11 @@ def _get_mounted_filestore(fsp): except FileNotFoundError: return None return filestore - + class BaseHandler(APIHandler): _home_file_share_path_cache = {} + _groups_cache = {} def get_current_user(self): """ @@ -98,6 +101,36 @@ def get_home_file_share_path_name(self): return None + def get_user_groups(self): + """ + Get the groups for the current user. + + Returns: + list: List of group names the user belongs to. + """ + username = self.get_current_user() + + if username in self._groups_cache: + return self._groups_cache[username] + + try: + user_info = pwd.getpwnam(username) + user_groups = [] + all_groups = grp.getgrall() # Get all groups on the system + for group in all_groups: + if username in group.gr_mem: # Check if user is a member of this group + user_groups.append(group.gr_name) + primary_group = grp.getgrgid(user_info.pw_gid).gr_name + if primary_group not in user_groups: # Add primary group if not already included + user_groups.append(primary_group) + self._groups_cache[username] = user_groups + return user_groups + except Exception as e: + self.log.error(f"Error getting groups for user {username}: {str(e)}") + self._groups_cache[username] = [] + return [] + + class StreamingProxy(BaseHandler): """ API handler for proxying responses from the central server @@ -132,6 +165,7 @@ class FileSharePathsHandler(BaseHandler): def get(self): self.log.info("GET /api/fileglancer/file-share-paths") file_share_paths = get_fsp_manager(self.settings).get_file_share_paths() + self.set_header('Content-Type', 'application/json') self.set_status(200) # Convert Pydantic objects to dicts before JSON serialization @@ -140,7 +174,6 @@ def get(self): self.finish() - class FileShareHandler(BaseHandler, ABC): """ Abstract base handler for endpoints that use the Filestore class. @@ -1028,11 +1061,13 @@ def get(self): username = self.get_current_user() home_fsp_name = self.get_home_file_share_path_name() home_directory_name = os.path.basename(self.get_home_directory_path()) - self.log.info(f"GET /api/fileglancer/profile username={username} home_fsp_name={home_fsp_name} home_directory_name={home_directory_name}") + groups = self.get_user_groups() + self.log.info(f"GET /api/fileglancer/profile username={username} home_fsp_name={home_fsp_name} home_directory_name={home_directory_name} groups={groups}") response = { "username": username, "homeFileSharePathName": home_fsp_name, "homeDirectoryName": home_directory_name, + "groups": groups, } try: self.set_status(200) diff --git a/src/__tests__/test-utils.tsx b/src/__tests__/test-utils.tsx index b610dcfb..8d99a216 100644 --- a/src/__tests__/test-utils.tsx +++ b/src/__tests__/test-utils.tsx @@ -14,6 +14,7 @@ import { OpenFavoritesProvider } from '@/contexts/OpenFavoritesContext'; import { TicketProvider } from '@/contexts/TicketsContext'; import { ProfileContextProvider } from '@/contexts/ProfileContext'; import { ExternalBucketProvider } from '@/contexts/ExternalBucketContext'; +import { CentralServerHealthProvider } from '@/contexts/CentralServerHealthContext'; import ErrorFallback from '@/components/ErrorFallback'; interface CustomRenderOptions extends Omit { @@ -39,21 +40,23 @@ const FileBrowserTestingWrapper = ({ const Browse = ({ children }: { children: React.ReactNode }) => { return ( - - - - - - - - {children} - - - - - - - + + + + + + + + + {children} + + + + + + + + ); }; diff --git a/src/components/Preferences.tsx b/src/components/Preferences.tsx index af5c0215..d8101444 100644 --- a/src/components/Preferences.tsx +++ b/src/components/Preferences.tsx @@ -11,11 +11,13 @@ export default function Preferences() { pathPreference, handlePathPreferenceSubmit, hideDotFiles, + isFilteredByGroups, toggleHideDotFiles, disableNeuroglancerStateGeneration, toggleDisableNeuroglancerStateGeneration, disableHeuristicalLayerTypeDetection, - toggleDisableHeuristicalLayerTypeDetection + toggleDisableHeuristicalLayerTypeDetection, + toggleFilterByGroups } = usePreferencesContext(); return ( @@ -124,6 +126,34 @@ export default function Preferences() { Options: +
+ { + const result = await toggleFilterByGroups(); + if (result.success) { + toast.success( + !isFilteredByGroups + ? 'Only Zones for groups you have membership in are now visible' + : 'All Zones are now visible' + ); + } else { + toast.error(result.error); + } + }} + type="checkbox" + /> + + Display Zones for your groups only + +
+
- No favorites match your filter. + No favorites match your filter '{searchQuery}' - Try broadening your search to see more results. + Try broadening your search to see more results
) : ( diff --git a/src/components/ui/Sidebar/Sidebar.tsx b/src/components/ui/Sidebar/Sidebar.tsx index 99f2d4e8..f3fab0e9 100644 --- a/src/components/ui/Sidebar/Sidebar.tsx +++ b/src/components/ui/Sidebar/Sidebar.tsx @@ -4,7 +4,7 @@ import { HiOutlineFunnel, HiXMark } from 'react-icons/hi2'; import FavoritesBrowser from './FavoritesBrowser'; import ZonesBrowser from './ZonesBrowser'; -import useSearchFilter from '@/hooks/useSearchFilter'; +import useFilteredZonesAndFavorites from '@/hooks/useFilteredZonesAndFavorites'; export default function Sidebar() { const { @@ -15,7 +15,8 @@ export default function Sidebar() { filteredZoneFavorites, filteredFileSharePathFavorites, filteredFolderFavorites - } = useSearchFilter(); + } = useFilteredZonesAndFavorites(); + return (
diff --git a/src/components/ui/Sidebar/ZonesBrowser.tsx b/src/components/ui/Sidebar/ZonesBrowser.tsx index 0e807f56..d595de57 100644 --- a/src/components/ui/Sidebar/ZonesBrowser.tsx +++ b/src/components/ui/Sidebar/ZonesBrowser.tsx @@ -4,9 +4,11 @@ import { HiSquares2X2 } from 'react-icons/hi2'; import { ZonesAndFileSharePathsMap } from '@/shared.types'; import { useZoneAndFspMapContext } from '@/contexts/ZonesAndFspMapContext'; +import { usePreferencesContext } from '@/contexts/PreferencesContext'; import useOpenZones from '@/hooks/useOpenZones'; import Zone from './Zone'; import { SidebarItemSkeleton } from '@/components/ui/widgets/Loaders'; +import { Link } from 'react-router'; export default function ZonesBrowser({ searchQuery, @@ -17,6 +19,7 @@ export default function ZonesBrowser({ }) { const { zonesAndFileSharePathsMap, areZoneDataLoading } = useZoneAndFspMapContext(); + const { isFilteredByGroups } = usePreferencesContext(); const { openZones, toggleOpenZones } = useOpenZones(); const displayZones: ZonesAndFileSharePathsMap = @@ -58,10 +61,10 @@ export default function ZonesBrowser({ Object.keys(displayZones).length === 0 ? (
- No zones match your filter. + No zones match your filter '{searchQuery}' - Try broadening your search to see more results. + Try broadening your search to see more results
) : ( @@ -78,6 +81,36 @@ export default function ZonesBrowser({ } }) )} + +
+ {isFilteredByGroups ? ( + <> + + Viewing Zones for your groups only + + + Modify your{' '} + + preferences + {' '} + to see all Zones + + + ) : ( + <> + + Viewing all Zones + + + Modify your{' '} + + preferences + {' '} + to see Zones for your groups only + + + )} +
)} diff --git a/src/contexts/PreferencesContext.tsx b/src/contexts/PreferencesContext.tsx index fe6bb30f..076f6b2d 100644 --- a/src/contexts/PreferencesContext.tsx +++ b/src/contexts/PreferencesContext.tsx @@ -3,8 +3,9 @@ import { default as log } from '@/logger'; import type { FileSharePath, Zone } from '@/shared.types'; import { useCookiesContext } from '@/contexts/CookiesContext'; -import { useZoneAndFspMapContext } from './ZonesAndFspMapContext'; -import { useFileBrowserContext } from './FileBrowserContext'; +import { useZoneAndFspMapContext } from '@/contexts/ZonesAndFspMapContext'; +import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; +import { useCentralServerHealthContext } from '@/contexts/CentralServerHealthContext'; import { sendFetchRequest, makeMapKey, HTTPError } from '@/utils'; import { createSuccess, handleError, toHttpError } from '@/utils/errorHandling'; import type { Result } from '@/shared.types'; @@ -62,6 +63,8 @@ type PreferencesContextType = { loadingRecentlyViewedFolders: boolean; isLayoutLoadedFromDB: boolean; handleContextMenuFavorite: () => Promise>; + isFilteredByGroups: boolean; + toggleFilterByGroups: () => Promise>; }; const PreferencesContext = React.createContext( @@ -124,31 +127,34 @@ export const PreferencesProvider = ({ const [layout, setLayout] = React.useState(''); const [isLayoutLoadedFromDB, setIsLayoutLoadedFromDB] = React.useState(false); + const { status } = useCentralServerHealthContext(); + // If Central Server status is 'ignore', default to false to skip filtering by groups + const [isFilteredByGroups, setIsFilteredByGroups] = React.useState( + status === 'ignore' ? false : true + ); + const { cookies } = useCookiesContext(); const { isZonesMapReady, zonesAndFileSharePathsMap } = useZoneAndFspMapContext(); const { fileBrowserState } = useFileBrowserContext(); - const fetchPreferences = React.useCallback( - async (key: string) => { - try { - const data = await sendFetchRequest( - `/api/fileglancer/preference?key=${key}`, - 'GET', - cookies['_xsrf'] - ).then(response => response.json()); - return data?.value; - } catch (error) { - if (error instanceof HTTPError && error.responseCode === 404) { - return null; // Preference not found is not an error - } else { - log.error(`Error fetching preference '${key}':`, error); - } - return null; + const fetchPreferences = React.useCallback(async () => { + try { + const data = await sendFetchRequest( + `/api/fileglancer/preference`, + 'GET', + cookies['_xsrf'] + ).then(response => response.json()); + return data; + } catch (error) { + if (error instanceof HTTPError && error.responseCode === 404) { + return {}; // No preferences found, return empty object + } else { + log.error(`Error fetching preferences:`, error); } - }, - [cookies] - ); + return {}; + } + }, [cookies]); const accessMapItems = React.useCallback( (keys: string[]) => { @@ -291,6 +297,17 @@ export const PreferencesProvider = ({ [savePreferencesToBackend] ); + const toggleFilterByGroups = React.useCallback(async (): Promise< + Result + > => { + if (status === 'ignore') { + return handleError( + new Error('Cannot filter by groups; central server configuration issue') + ); + } + return await togglePreference('isFilteredByGroups', setIsFilteredByGroups); + }, [togglePreference, status]); + const toggleHideDotFiles = React.useCallback(async (): Promise< Result > => { @@ -539,132 +556,47 @@ export const PreferencesProvider = ({ [recentlyViewedFolders] ); - React.useEffect(() => { - (async function () { - if (isLayoutLoadedFromDB) { - return; // Avoid re-fetching if already loaded - } - const rawLayoutPref = await fetchPreferences('layout'); - if (rawLayoutPref) { - setLayout(rawLayoutPref); - } - setIsLayoutLoadedFromDB(true); - })(); - }, [fetchPreferences, isLayoutLoadedFromDB]); - - React.useEffect(() => { - (async function () { - const rawPathPreference = await fetchPreferences('path'); - if (rawPathPreference) { - setPathPreference(rawPathPreference); - } - })(); - }, [fetchPreferences]); - - React.useEffect(() => { - (async function () { - const rawHideDotFiles = await fetchPreferences('hideDotFiles'); - if (rawHideDotFiles !== null) { - setHideDotFiles(rawHideDotFiles); - } - })(); - }, [fetchPreferences]); - - React.useEffect(() => { - (async function () { - const rawAreDataLinksAutomatic = await fetchPreferences( - 'areDataLinksAutomatic' - ); - if (rawAreDataLinksAutomatic !== null) { - setAreDataLinksAutomatic(rawAreDataLinksAutomatic); - } - })(); - }, [fetchPreferences]); - - React.useEffect(() => { - (async function () { - const rawDisableNeuroglancerStateGeneration = await fetchPreferences( - 'disableNeuroglancerStateGeneration' - ); - if (rawDisableNeuroglancerStateGeneration !== null) { - setDisableNeuroglancerStateGeneration( - rawDisableNeuroglancerStateGeneration - ); - } - })(); - }, [fetchPreferences]); - - React.useEffect(() => { - (async function () { - const rawDisableHeuristicalLayerTypeDetection = await fetchPreferences( - 'disableHeuristicalLayerTypeDetection' - ); - if (rawDisableHeuristicalLayerTypeDetection !== null) { - setDisableHeuristicalLayerTypeDetection( - rawDisableHeuristicalLayerTypeDetection - ); - } - })(); - }, [fetchPreferences]); - - React.useEffect(() => { - (async function () { - const rawUseLegacyMultichannelApproach = await fetchPreferences( - 'useLegacyMultichannelApproach' - ); - if (rawUseLegacyMultichannelApproach !== null) { - setUseLegacyMultichannelApproach(rawUseLegacyMultichannelApproach); - } - })(); - }, [fetchPreferences]); - + // Fetch all preferences on mount React.useEffect(() => { if (!isZonesMapReady) { return; } + if (isLayoutLoadedFromDB) { + return; // Avoid re-fetching if already loaded + } + setLoadingRecentlyViewedFolders(true); (async function () { - const backendPrefs = await fetchPreferences('zone'); + const allPrefs = await fetchPreferences(); + + // Zone favorites + const zoneBackendPrefs = allPrefs.zone?.value; const zoneArray = - backendPrefs?.map((pref: ZonePreference) => { + zoneBackendPrefs?.map((pref: ZonePreference) => { const key = makeMapKey(pref.type, pref.name); return { [key]: pref }; }) || []; const zoneMap = Object.assign({}, ...zoneArray); - if (zoneMap) { + if (Object.keys(zoneMap).length > 0) { updateLocalZonePreferenceStates(zoneMap); } - })(); - }, [isZonesMapReady, fetchPreferences, updateLocalZonePreferenceStates]); - React.useEffect(() => { - if (!isZonesMapReady) { - return; - } - - (async function () { - const backendPrefs = await fetchPreferences('fileSharePath'); + // FileSharePath favorites + const fspBackendPrefs = allPrefs.fileSharePath?.value; const fspArray = - backendPrefs?.map((pref: FileSharePathPreference) => { + fspBackendPrefs?.map((pref: FileSharePathPreference) => { const key = makeMapKey(pref.type, pref.name); return { [key]: pref }; }) || []; const fspMap = Object.assign({}, ...fspArray); - if (fspMap) { + if (Object.keys(fspMap).length > 0) { updateLocalFspPreferenceStates(fspMap); } - })(); - }, [isZonesMapReady, fetchPreferences, updateLocalFspPreferenceStates]); - React.useEffect(() => { - if (!isZonesMapReady) { - return; - } - - (async function () { - const backendPrefs = await fetchPreferences('folder'); + // Folder favorites + const folderBackendPrefs = allPrefs.folder?.value; const folderArray = - backendPrefs?.map((pref: FolderPreference) => { + folderBackendPrefs?.map((pref: FolderPreference) => { const key = makeMapKey( pref.type, `${pref.fspName}_${pref.folderPath}` @@ -672,28 +604,62 @@ export const PreferencesProvider = ({ return { [key]: pref }; }) || []; const folderMap = Object.assign({}, ...folderArray); - if (folderMap) { + if (Object.keys(folderMap).length > 0) { updateLocalFolderPreferenceStates(folderMap); } - })(); - }, [isZonesMapReady, fetchPreferences, updateLocalFolderPreferenceStates]); - // Get initial recently viewed folders from backend - React.useEffect(() => { - setLoadingRecentlyViewedFolders(true); - if (!isZonesMapReady) { - return; - } - (async function () { - const backendPrefs = (await fetchPreferences( - 'recentlyViewedFolders' - )) as FolderPreference[]; - if (backendPrefs && backendPrefs.length > 0) { - setRecentlyViewedFolders(backendPrefs); + // Recently viewed folders + const recentlyViewedBackendPrefs = allPrefs.recentlyViewedFolders?.value; + if (recentlyViewedBackendPrefs && recentlyViewedBackendPrefs.length > 0) { + setRecentlyViewedFolders(recentlyViewedBackendPrefs); + } + + // Layout preference + if (allPrefs.layout?.value) { + setLayout(allPrefs.layout.value); + } + + // Path preference + if (allPrefs.path?.value) { + setPathPreference(allPrefs.path.value); + } + + // Boolean preferences + if (allPrefs.isFilteredByGroups?.value !== undefined) { + setIsFilteredByGroups(allPrefs.isFilteredByGroups.value); + } + if (allPrefs.hideDotFiles?.value !== undefined) { + setHideDotFiles(allPrefs.hideDotFiles.value); + } + if (allPrefs.areDataLinksAutomatic?.value !== undefined) { + setAreDataLinksAutomatic(allPrefs.areDataLinksAutomatic.value); + } + if (allPrefs.disableNeuroglancerStateGeneration?.value !== undefined) { + setDisableNeuroglancerStateGeneration( + allPrefs.disableNeuroglancerStateGeneration.value + ); + } + if (allPrefs.disableHeuristicalLayerTypeDetection?.value !== undefined) { + setDisableHeuristicalLayerTypeDetection( + allPrefs.disableHeuristicalLayerTypeDetection.value + ); + } + if (allPrefs.useLegacyMultichannelApproach?.value !== undefined) { + setUseLegacyMultichannelApproach( + allPrefs.useLegacyMultichannelApproach.value + ); } setLoadingRecentlyViewedFolders(false); + setIsLayoutLoadedFromDB(true); })(); - }, [fetchPreferences, isZonesMapReady]); + }, [ + fetchPreferences, + isZonesMapReady, + isLayoutLoadedFromDB, + updateLocalZonePreferenceStates, + updateLocalFspPreferenceStates, + updateLocalFolderPreferenceStates + ]); // Store last viewed folder path and FSP name to avoid duplicate updates const lastFolderPathRef = React.useRef(null); @@ -787,7 +753,9 @@ export const PreferencesProvider = ({ setLayoutWithPropertiesOpen, loadingRecentlyViewedFolders, isLayoutLoadedFromDB, - handleContextMenuFavorite + handleContextMenuFavorite, + isFilteredByGroups, + toggleFilterByGroups }} > {children} diff --git a/src/contexts/ProfileContext.tsx b/src/contexts/ProfileContext.tsx index 4368eb30..4ed0eede 100644 --- a/src/contexts/ProfileContext.tsx +++ b/src/contexts/ProfileContext.tsx @@ -8,6 +8,7 @@ type Profile = { username: string; homeFileSharePathName: string; homeDirectoryName: string; + groups: string[]; }; type ProfileContextType = { diff --git a/src/hooks/useSearchFilter.ts b/src/hooks/useFilteredZonesAndFavorites.ts similarity index 68% rename from src/hooks/useSearchFilter.ts rename to src/hooks/useFilteredZonesAndFavorites.ts index 9f8bfabd..728293a6 100644 --- a/src/hooks/useSearchFilter.ts +++ b/src/hooks/useFilteredZonesAndFavorites.ts @@ -10,11 +10,17 @@ import { FolderFavorite, usePreferencesContext } from '@/contexts/PreferencesContext'; +import { useProfileContext } from '@/contexts/ProfileContext'; -export default function useSearchFilter() { +export default function useFilteredZonesAndFavorites() { const { zonesAndFileSharePathsMap } = useZoneAndFspMapContext(); - const { zoneFavorites, fileSharePathFavorites, folderFavorites } = - usePreferencesContext(); + const { + zoneFavorites, + fileSharePathFavorites, + folderFavorites, + isFilteredByGroups + } = usePreferencesContext(); + const { profile } = useProfileContext(); const [searchQuery, setSearchQuery] = React.useState(''); const [filteredZonesMap, setFilteredZonesMap] = @@ -30,6 +36,8 @@ export default function useSearchFilter() { const filterZonesMap = React.useCallback( (query: string) => { + const userGroups = profile?.groups || []; + const matches = Object.entries(zonesAndFileSharePathsMap) .map(([key, value]) => { if (key.startsWith('zone')) { @@ -37,10 +45,17 @@ export default function useSearchFilter() { const zoneNameMatches = zone.name.toLowerCase().includes(query); // Filter the file share paths inside the zone - const matchingFileSharePaths = zone.fileSharePaths.filter(fsp => + let matchingFileSharePaths = zone.fileSharePaths.filter(fsp => fsp.name.toLowerCase().includes(query) ); + // Apply group filtering if enabled + if (isFilteredByGroups && userGroups.length > 0) { + matchingFileSharePaths = matchingFileSharePaths.filter( + fsp => userGroups.includes(fsp.group) || fsp.group === 'public' + ); + } + // If Zone.name matches or any FileSharePath.name inside the zone matches, // return a modified Zone object with only the matching file share paths if (zoneNameMatches || matchingFileSharePaths.length > 0) { @@ -59,7 +74,7 @@ export default function useSearchFilter() { setFilteredZonesMap(Object.fromEntries(matches as [string, Zone][])); }, - [zonesAndFileSharePathsMap] + [zonesAndFileSharePathsMap, isFilteredByGroups, profile] ); const filterAllFavorites = React.useCallback( @@ -112,8 +127,37 @@ export default function useSearchFilter() { if (searchQuery !== '') { filterZonesMap(searchQuery); filterAllFavorites(searchQuery); - } else if (searchQuery === '') { - // When search query is empty, use all the original paths + } else if (searchQuery === '' && isFilteredByGroups && profile?.groups) { + // When search query is empty but group filtering is enabled, apply group filter + const userGroups = profile.groups; + const groupFilteredMap = Object.entries(zonesAndFileSharePathsMap) + .map(([key, value]) => { + if (key.startsWith('zone')) { + const zone = value as Zone; + const matchingFileSharePaths = zone.fileSharePaths.filter( + fsp => userGroups.includes(fsp.group) || fsp.group === 'public' + ); + if (matchingFileSharePaths.length > 0) { + return [ + key, + { + ...zone, + fileSharePaths: matchingFileSharePaths + } + ]; + } + } + return null; + }) + .filter(Boolean); + setFilteredZonesMap( + Object.fromEntries(groupFilteredMap as [string, Zone][]) + ); + setFilteredZoneFavorites([]); + setFilteredFileSharePathFavorites([]); + setFilteredFolderFavorites([]); + } else { + // When search query is empty and group filtering is disabled, use all the original paths setFilteredZonesMap({}); setFilteredZoneFavorites([]); setFilteredFileSharePathFavorites([]); @@ -126,7 +170,9 @@ export default function useSearchFilter() { fileSharePathFavorites, folderFavorites, filterAllFavorites, - filterZonesMap + filterZonesMap, + isFilteredByGroups, + profile ]); return { diff --git a/ui-tests/README.md b/ui-tests/README.md index 91825145..4f8fa078 100644 --- a/ui-tests/README.md +++ b/ui-tests/README.md @@ -10,158 +10,22 @@ The Playwright configuration is defined in [playwright.config.js](./playwright.c The JupyterLab server configuration to use for the integration test is defined in [jupyter_server_test_config.py](./jupyter_server_test_config.py). -The default configuration will produce video for failing tests and an HTML report. - -> There is a UI mode that you may like; see [that video](https://www.youtube.com/watch?v=jF0yA-JLQW0). - ## Run the tests > All commands are assumed to be executed from the root directory To run the tests, you need to: -1. Compile the extension: - -```sh -jlpm install -jlpm build:prod -``` - -> Check the extension is installed in JupyterLab. - -2. Install test dependencies (needed only once): - -```sh -cd ./ui-tests -jlpm install -jlpm playwright install -cd .. -``` - -3. Execute the [Playwright](https://playwright.dev/docs/intro) tests: - -```sh -cd ./ui-tests -jlpm playwright test -``` - -Test results will be shown in the terminal. In case of any test failures, the test report -will be opened in your browser at the end of the tests execution; see -[Playwright documentation](https://playwright.dev/docs/test-reporters#html-reporter) -for configuring that behavior. - -## Update the tests snapshots - -> All commands are assumed to be executed from the root directory - -If you are comparing snapshots to validate your tests, you may need to update -the reference snapshots stored in the repository. To do that, you need to: - -1. Compile the extension: - -```sh -jlpm install -jlpm build:prod -``` - -> Check the extension is installed in JupyterLab. - -2. Install test dependencies (needed only once): - -```sh -cd ./ui-tests -jlpm install -jlpm playwright install -cd .. -``` - -3. Execute the [Playwright](https://playwright.dev/docs/intro) command: - -```sh -cd ./ui-tests -jlpm playwright test -u -``` - -> Some discrepancy may occurs between the snapshots generated on your computer and -> the one generated on the CI. To ease updating the snapshots on a PR, you can -> type `please update playwright snapshots` to trigger the update by a bot on the CI. -> Once the bot has computed new snapshots, it will commit them to the PR branch. - -## Create tests +Install test dependencies (needed only once): -> All commands are assumed to be executed from the root directory - -To create tests, the easiest way is to use the code generator tool of playwright: - -1. Compile the extension: - -```sh -jlpm install -jlpm build:prod -``` - -> Check the extension is installed in JupyterLab. - -2. Install test dependencies (needed only once): - -```sh -cd ./ui-tests -jlpm install -jlpm playwright install -cd .. -``` - -3. Start the server: - -```sh -cd ./ui-tests -jlpm start -``` - -4. Execute the [Playwright code generator](https://playwright.dev/docs/codegen) in **another terminal**: - -```sh -cd ./ui-tests -jlpm playwright codegen localhost:8888 +```bash +pixi run npx --prefix ui-tests playwright install ``` -## Debug tests +To execute the UI integration test, run: -> All commands are assumed to be executed from the root directory - -To debug tests, a good way is to use the inspector tool of playwright: - -1. Compile the extension: - -```sh -jlpm install -jlpm build:prod -``` - -> Check the extension is installed in JupyterLab. - -2. Install test dependencies (needed only once): - -```sh -cd ./ui-tests -jlpm install -jlpm playwright install -cd .. -``` - -3. Execute the Playwright tests in [debug mode](https://playwright.dev/docs/debug): - -```sh -cd ./ui-tests -jlpm playwright test --debug +```bash +pixi run ui-test ``` -## Upgrade Playwright and the browsers - -To update the web browser versions, you must update the package `@playwright/test`: - -```sh -cd ./ui-tests -jlpm up "@playwright/test" -jlpm playwright install -``` +For more information, please refer to the [Development](../docs/Development.md#integration-tests) documentation. diff --git a/ui-tests/tests/localApp/fileglancer.spec.ts b/ui-tests/tests/localApp/fileglancer.spec.ts index c770535c..f4ee8d36 100644 --- a/ui-tests/tests/localApp/fileglancer.spec.ts +++ b/ui-tests/tests/localApp/fileglancer.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@jupyterlab/galata'; import { openFileGlancer } from '../testutils.ts'; +import type { IJupyterLabPageFixture } from '@jupyterlab/galata'; /** * Don't load JupyterLab webpage before running the tests. @@ -16,11 +17,9 @@ test('should emit an activation console message', async ({ page }) => { await page.goto(); - await expect( + expect( logs.filter(s => s === 'JupyterLab extension fileglancer is activated!') ).toHaveLength(1); - // we are still on the JupyterLab page - await expect(page).toHaveTitle('JupyterLab'); }); test('when fg icon clicked should open fileglancer extension', async ({ @@ -33,7 +32,5 @@ test('when fg icon clicked should open fileglancer extension', async ({ }); await openFileGlancer(page); - - // we are still on the Fileglancer page - await expect(page).toHaveTitle('Fileglancer'); + await expect(page.getByText('Browse')).toBeVisible(); }); diff --git a/ui-tests/tests/localApp/localzone.spec.ts b/ui-tests/tests/localApp/localzone.spec.ts index ec4759ea..807c478f 100644 --- a/ui-tests/tests/localApp/localzone.spec.ts +++ b/ui-tests/tests/localApp/localzone.spec.ts @@ -6,7 +6,7 @@ test.beforeEach('Open fileglancer', async ({ page }) => { }); test('Home becomes visible when Local is expanded', async ({ page }) => { - const zonesLocator = page.getByText('Zones'); + const zonesLocator = page.getByText('Zones', { exact: true }); const homeLocator = page.getByRole('link', { name: 'home', exact: true }); const localZoneLocator = page.getByText('Local'); diff --git a/ui-tests/tests/testutils.ts b/ui-tests/tests/testutils.ts index c33b7dae..30a1a418 100644 --- a/ui-tests/tests/testutils.ts +++ b/ui-tests/tests/testutils.ts @@ -1,4 +1,5 @@ import { Page } from '@playwright/test'; +import type { IJupyterLabPageFixture } from '@jupyterlab/galata'; const sleepInSecs = (secs: number) => new Promise(resolve => setTimeout(resolve, secs * 1000));