diff --git a/src/components/VCopyButton.vue b/src/components/VCopyButton.vue index 83b448599c..a47bc45159 100644 --- a/src/components/VCopyButton.vue +++ b/src/components/VCopyButton.vue @@ -4,7 +4,7 @@ type="button" variant="secondary-filled" size="disabled" - class="py-2 px-3 text-sr" + class="py-2 px-3 text-sr font-semibold" :data-clipboard-target="el" > diff --git a/src/components/VHeaderOld/VHeaderFilter.vue b/src/components/VHeaderOld/VHeaderFilter.vue index c3f9b78dc6..f2c791cbe5 100644 --- a/src/components/VHeaderOld/VHeaderFilter.vue +++ b/src/components/VHeaderOld/VHeaderFilter.vue @@ -41,8 +41,6 @@ import { watch, computed, onMounted, - inject, - Ref, toRef, onBeforeUnmount, } from '@nuxtjs/composition-api' @@ -52,6 +50,7 @@ import { Portal as VTeleport } from 'portal-vue' import { useBodyScrollLock } from '~/composables/use-body-scroll-lock' import { useFilterSidebarVisibility } from '~/composables/use-filter-sidebar-visibility' import { useFocusFilters } from '~/composables/use-focus-filters' +import { isMinScreen } from '~/composables/use-media-query' import { Focus } from '~/utils/focus-management' import { defineEvent } from '~/types/emits' @@ -93,7 +92,9 @@ export default defineComponent({ const filterSidebar = useFilterSidebarVisibility() const disabledRef = toRef(props, 'disabled') - const isMinScreenMd: Ref = inject('isMinScreenMd') + // The `onMounted` in this component is run before the parent components' `onMounted` is run. + // The injected `isMinScreenMd` value can become true only after `default` layout's `onMounted` is run, so we need a separate check for `md` here to make sure that the value in `onMounted` is correct. + const isMinScreenMd = isMinScreen('md') const open = () => (visibleRef.value = true) const close = () => (visibleRef.value = false) diff --git a/src/layouts/default.vue b/src/layouts/default.vue index 1bd3db9ac7..4953c108a3 100644 --- a/src/layouts/default.vue +++ b/src/layouts/default.vue @@ -110,7 +110,6 @@ const embeddedPage = { mounted.value = true }) - const isMinScreenMd = isMinScreen('md') /** * If we use the `isMinScreen('lg')` composable for conditionally * rendering components, we get a server-client side rendering @@ -124,6 +123,10 @@ const embeddedPage = { const isMinScreenLg = computed(() => Boolean(innerIsMinScreenLg.value && mounted.value) ) + const innerIsMinScreenMd = isMinScreen('md') + const isMinScreenMd = computed(() => + Boolean(innerIsMinScreenMd.value && mounted.value) + ) const isSidebarVisible = computed(() => { return isNewHeaderEnabled.value diff --git a/src/pages/search.vue b/src/pages/search.vue index cf8aa28ac8..f6046d3e32 100644 --- a/src/pages/search.vue +++ b/src/pages/search.vue @@ -37,9 +37,6 @@ import { isShallowEqualObjects } from '@wordpress/is-shallow-equal' import { computed, defineComponent, inject, ref } from '@nuxtjs/composition-api' -import { Context } from '@nuxt/types' - -import { isMinScreen } from '~/composables/use-media-query' import { useFilterSidebarVisibility } from '~/composables/use-filter-sidebar-visibility' import { Focus, focusIn } from '~/utils/focus-management' import { useMediaStore } from '~/stores/media' @@ -49,6 +46,8 @@ import VSearchGrid from '~/components/VSearchGrid.vue' import VSkipToContentContainer from '~/components/VSkipToContentContainer.vue' import VScrollButton from '~/components/VScrollButton.vue' +import type { Context } from '@nuxt/types' + export default defineComponent({ name: 'BrowsePage', components: { @@ -68,7 +67,6 @@ export default defineComponent({ scrollToTop: false, setup() { const searchGridRef = ref(null) - const isMinScreenMd = isMinScreen('md') const { isVisible: isFilterSidebarVisible } = useFilterSidebarVisibility() const showScrollButton = inject('showScrollButton') const mediaStore = useMediaStore() @@ -99,7 +97,6 @@ export default defineComponent({ return { searchGridRef, - isMinScreenMd, isFilterSidebarVisible, showScrollButton, searchTerm, @@ -156,9 +153,9 @@ export default defineComponent({ } focusIn(document.getElementById('__layout'), Focus.First) }, - fetchMedia(...args: unknown[]) { + fetchMedia(payload: { shouldPersistMedia?: boolean } = {}) { const mediaStore = useMediaStore(this.$pinia) - return mediaStore.fetchMedia(...args) + return mediaStore.fetchMedia(payload) }, }, }) diff --git a/src/pages/search/audio.vue b/src/pages/search/audio.vue index 25c34271b5..affa0ed315 100644 --- a/src/pages/search/audio.vue +++ b/src/pages/search/audio.vue @@ -39,15 +39,19 @@ import { defineComponent, useMeta, ref, + inject, + watch, + toRef, } from '@nuxtjs/composition-api' -import { isMinScreen } from '~/composables/use-media-query' import { useBrowserIsMobile } from '~/composables/use-browser-detection' import { useFocusFilters } from '~/composables/use-focus-filters' import { Focus } from '~/utils/focus-management' import { useUiStore } from '~/stores/ui' +import { IsMinScreenMdKey } from '~/types/provides' + import VSnackbar from '~/components/VSnackbar.vue' import VAudioTrack from '~/components/VAudioTrack/VAudioTrack.vue' import VLoadMore from '~/components/VLoadMore.vue' @@ -69,16 +73,21 @@ export default defineComponent({ const results = computed(() => props.resultItems.audio) - const isMinScreenMd = isMinScreen('md', { shouldPassInSSR: true }) + const isMinScreenMd = inject(IsMinScreenMdKey) + const filterVisibleRef = toRef(props, 'isFilterVisible') // On SSR, we set the size to small if the User Agent is mobile, otherwise we set the size to medium. const isMobile = useBrowserIsMobile() - const audioTrackSize = computed(() => { - return !isMinScreenMd.value || isMobile - ? 's' - : props.isFilterVisible - ? 'l' - : 'm' + const audioTrackSize = ref( + !isMinScreenMd.value || isMobile ? 's' : props.isFilterVisible ? 'l' : 'm' + ) + + watch([filterVisibleRef, isMinScreenMd], ([filterVisible, isMd]) => { + if (!isMd) { + audioTrackSize.value = 's' + } else { + audioTrackSize.value = filterVisible ? 'l' : 'm' + } }) const focusFilters = useFocusFilters() diff --git a/test/playwright/e2e/filters.spec.ts b/test/playwright/e2e/filters.spec.ts new file mode 100644 index 0000000000..8b59a24181 --- /dev/null +++ b/test/playwright/e2e/filters.spec.ts @@ -0,0 +1,217 @@ +import { test, expect, Page } from '@playwright/test' + +import { + assertCheckboxStatus, + openFilters, + changeContentType, + goToSearchTerm, + enableNewHeader, + closeFilters, + isPageDesktop, +} from '~~/test/playwright/utils/navigation' + +import { mockProviderApis } from '~~/test/playwright/utils/route' + +import breakpoints from '~~/test/playwright/utils/breakpoints' + +import { + supportedSearchTypes, + ALL_MEDIA, + IMAGE, + AUDIO, +} from '~/constants/media' + +test.describe.configure({ mode: 'parallel' }) + +const assertCheckboxCount = async ( + page: Page, + checked: 'checked' | 'notChecked' | 'total', + count: number +) => { + const checkedString = { + checked: ':checked', + notChecked: ':not(:checked)', + total: '', + }[checked] + const locatorString = `input[type="checkbox"]${checkedString}` + await expect(page.locator(locatorString)).toHaveCount(count, { timeout: 200 }) +} + +const FILTER_COUNTS = { + [ALL_MEDIA]: 11, + [AUDIO]: 32, + [IMAGE]: 70, +} + +breakpoints.describeMobileAndDesktop(() => { + test.beforeEach(async ({ context, page }) => { + await mockProviderApis(context) + await enableNewHeader(page) + }) + for (const searchType of supportedSearchTypes) { + test(`correct total number of filters is displayed for ${searchType}`, async ({ + page, + }) => { + await goToSearchTerm(page, 'cat', { searchType }) + + await openFilters(page) + + await assertCheckboxCount(page, 'total', FILTER_COUNTS[searchType]) + }) + } + + test('initial filters are applied based on the url', async ({ page }) => { + await page.goto( + '/search/?q=cat&license_type=commercial&license=cc0&searchBy=creator' + ) + await openFilters(page) + const expectedFilters = ['cc0', 'commercial', 'creator'] + + for (const checkbox of expectedFilters) { + await assertCheckboxStatus(page, checkbox) + } + }) + + test('common filters are retained when media type changes from all media to single type', async ({ + page, + }) => { + await page.goto( + '/search/?q=cat&license_type=commercial&license=cc0&searchBy=creator' + ) + await openFilters(page) + const expectedFilters = ['cc0', 'commercial', 'creator'] + + for (const checkbox of expectedFilters) { + await assertCheckboxStatus(page, checkbox) + } + await changeContentType(page, 'Images') + + await expect(page).toHaveURL( + '/search/image?q=cat&license_type=commercial&license=cc0&searchBy=creator' + ) + await openFilters(page) + for (const checkbox of expectedFilters) { + await assertCheckboxStatus(page, checkbox) + } + }) + + test('common filters are retained when media type changes from single type to all media', async ({ + page, + }) => { + await page.goto( + '/search/image?q=cat&license_type=commercial&license=cc0&searchBy=creator' + ) + await openFilters(page) + + for (const checkbox of ['cc0', 'commercial', 'creator']) { + await assertCheckboxStatus(page, checkbox) + } + + await changeContentType(page, 'All content') + + await openFilters(page) + await expect(page.locator('input[type="checkbox"]:checked')).toHaveCount(3) + + await expect(page).toHaveURL( + '/search/?q=cat&license_type=commercial&license=cc0&searchBy=creator' + ) + }) + + test('selecting some filters can disable dependent filters', async ({ + page, + }) => { + await page.goto('/search/audio?q=cat&license_type=commercial') + await openFilters(page) + + // by-nc is special because we normally test for fuzzy match, and by-nc matches 3 labels. + const byNc = page.locator('input[value="by-nc"]') + await expect(byNc).toBeDisabled() + for (const checkbox of ['by-nc-sa', 'by-nc-nd']) { + await assertCheckboxStatus(page, checkbox, '', 'disabled') + } + await assertCheckboxStatus(page, 'commercial') + + await page.click('label:has-text("commercial")') + + await assertCheckboxStatus(page, 'commercial', '', 'unchecked') + await expect(byNc).not.toBeDisabled() + for (const checkbox of ['commercial', 'by-nc-sa', 'by-nc-nd']) { + await assertCheckboxStatus(page, checkbox, '', 'unchecked') + } + }) + + /** + * When the search type changes: + * - image-specific filter (aspect_ration=tall) is discarded + * - common filter (license_type=CC0) is kept + * - filter button text updates + * - URL updates + * Tests for the missing checkbox with `toHaveCount` are flaky, so we use filter button + * text and the URL instead. + */ + test('filters are updated when media type changes', async ({ page }) => { + await page.goto('/search/image?q=cat&aspect_ratio=tall&license=cc0') + await openFilters(page) + + await assertCheckboxStatus(page, 'tall') + await assertCheckboxStatus(page, 'cc0') + + await changeContentType(page, 'Audio') + await openFilters(page) + + // Only CC0 checkbox is checked, and the filter button label is + // '1 Filter' on `xl` or '1' on `lg` screens + await assertCheckboxStatus(page, 'cc0') + await closeFilters(page) + if (isPageDesktop(page)) { + const filterButtonText = await page + .locator('[aria-controls="filters"] span:visible') + .textContent() + expect(filterButtonText).toContain('1') + } else { + const filtersAriaLabel = + (await page + .locator('[aria-controls="content-settings-modal"]') + .getAttribute('aria-label')) ?? '' + expect(filtersAriaLabel.trim()).toEqual('Menu. 1 filter applied') + } + + await expect(page).toHaveURL('/search/audio?q=cat&license=cc0') + }) + + test('new media request is sent when a filter is selected', async ({ + page, + }) => { + await page.goto('/search/image?q=cat') + await openFilters(page) + + await assertCheckboxStatus(page, 'cc0', '', 'unchecked') + + const [response] = await Promise.all([ + page.waitForResponse((response) => response.url().includes('cc0')), + page.click('label:has-text("CC0")'), + ]) + + await assertCheckboxStatus(page, 'cc0') + // Remove the host url and path because when proxied, the 'http://localhost:49153' is used instead of the + // real API url + const queryString = response.url().split('/images/')[1] + expect(queryString).toEqual('?q=cat&license=cc0') + }) + + for (const [searchType, source] of [ + ['audio', 'Freesound'], + ['image', 'Flickr'], + ]) { + test(`Provider filters are correctly initialized from the URL: ${source} - ${searchType}`, async ({ + page, + }) => { + await page.goto( + `/search/${searchType}?q=birds&source=${source.toLowerCase()}` + ) + await openFilters(page) + + await assertCheckboxStatus(page, source, '', 'checked') + }) + } +}) diff --git a/test/playwright/e2e/header-internal.spec.ts b/test/playwright/e2e/header-internal.spec.ts index cb8b82134c..d58ef76280 100644 --- a/test/playwright/e2e/header-internal.spec.ts +++ b/test/playwright/e2e/header-internal.spec.ts @@ -1,59 +1,61 @@ -import { test, expect } from '@playwright/test' +import { test, expect, Page } from '@playwright/test' import { enableNewHeader, + isMobileMenuOpen, scrollToBottom, t, } from '~~/test/playwright/utils/navigation' - -test.use({ - viewport: { width: 640, height: 600 }, -}) - -test.skip('can open and close the modal on md breakpoint', async ({ page }) => { - await enableNewHeader(page) - - await page.goto('/about') - const menuAriaLabel = t('header.aria.menu') - - await page.locator(`[aria-label="${menuAriaLabel}"]`).click() - await expect(page.locator('[role="dialog"]')).toBeVisible() - await expect( - page.locator('div[role="dialog"] >> [aria-current="page"]') - ).toBeVisible() - await expect( - page.locator('div[role="dialog"] >> [aria-current="page"]') - ).toHaveText('About') - - await page.locator('div[role="dialog"] >> [aria-label="Close"]').click() - await expect(page.locator(`[aria-label="${menuAriaLabel}"]`)).toBeVisible() -}) - -test.skip('the modal locks the scroll on md breakpoint', async ({ page }) => { - await enableNewHeader(page) - - await page.goto('/about') - const menuAriaLabel = t('header.aria.menu') - - await scrollToBottom(page) - await page.locator(`[aria-label="${menuAriaLabel}"]`).click() - await page.locator('div[role="dialog"] >> [aria-label="Close"]').click() - - const scrollPosition = await page.evaluate(() => window.scrollY) - expect(scrollPosition).toBeGreaterThan(100) -}) - -test("the modal opens an external link in a new window and it doesn't close the modal", async ({ - page, -}) => { - await enableNewHeader(page) - - await page.goto('/about') - const menuAriaLabel = t('header.aria.menu') - - await scrollToBottom(page) - await page.locator(`[aria-label="${menuAriaLabel}"]`).click() - await page.locator('div[role="dialog"] >> text=API').click() - - await expect(page.locator('[role="dialog"]')).toBeVisible() +import breakpoints from '~~/test/playwright/utils/breakpoints' + +const modalCloseButton = 'div[role="dialog"] >> [aria-label="Close"]' +const currentPageLink = 'div[role="dialog"] >> [aria-current="page"]' +const menuButton = `[aria-label="${t('header.aria.menu')}"]` + +const openMenu = async (page: Page) => await page.click(menuButton) +const closeMenu = async (page: Page) => await page.click(modalCloseButton) + +test.describe('Header internal', () => { + breakpoints.describeSm(() => { + test.beforeEach(async ({ page }) => { + await enableNewHeader(page) + await page.goto('/about') + }) + test('can open and close the modal on md breakpoint', async ({ page }) => { + await openMenu(page) + expect(await isMobileMenuOpen(page)).toBe(true) + await expect(page.locator(currentPageLink)).toBeVisible() + await expect(page.locator(currentPageLink)).toHaveText('About') + + await closeMenu(page) + expect(await isMobileMenuOpen(page)).toBe(false) + await expect(page.locator(menuButton)).toBeVisible() + }) + + test('the modal locks the scroll on md breakpoint', async ({ page }) => { + await scrollToBottom(page) + + await openMenu(page) + await closeMenu(page) + + const scrollPosition = await page.evaluate(() => window.scrollY) + expect(scrollPosition).toBeGreaterThan(100) + }) + + test("the modal opens an external link in a new window and it doesn't close the modal", async ({ + page, + }) => { + await scrollToBottom(page) + await openMenu(page) + + // Open the external link in a new tab, close the tab + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + page.locator('div[role="dialog"] >> text=API').click(), + ]) + await popup.close() + + expect(await isMobileMenuOpen(page)).toBe(true) + }) + }) }) diff --git a/test/playwright/e2e/mobile-menu.spec.ts b/test/playwright/e2e/mobile-menu.spec.ts new file mode 100644 index 0000000000..08e52ac768 --- /dev/null +++ b/test/playwright/e2e/mobile-menu.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from '@playwright/test' + +import { + closeFilters, + closeMobileMenu, + enableNewHeader, + goToSearchTerm, + isMobileMenuOpen, + openContentTypes, + openFilters, +} from '~~/test/playwright/utils/navigation' +import breakpoints from '~~/test/playwright/utils/breakpoints' + +test.describe.configure({ mode: 'parallel' }) + +test.describe('mobile menu', () => { + breakpoints.describeSm(() => { + test.beforeEach(async ({ page }) => { + await enableNewHeader(page) + }) + + test('Can open filters menu on mobile at least twice', async ({ page }) => { + await page.goto('/search/?q=cat') + + await openFilters(page) + expect(await isMobileMenuOpen(page)).toBe(true) + await closeFilters(page) + + await openFilters(page) + expect(await isMobileMenuOpen(page)).toBe(true) + await closeFilters(page) + expect(await isMobileMenuOpen(page)).toBe(false) + }) + + test('Can open mobile menu at least twice', async ({ page }) => { + await goToSearchTerm(page, 'cat') + await openContentTypes(page) + expect(await isMobileMenuOpen(page)).toBe(true) + await closeMobileMenu(page) + + await openContentTypes(page) + expect(await isMobileMenuOpen(page)).toBe(true) + await closeMobileMenu(page) + expect(await isMobileMenuOpen(page)).toBe(false) + }) + }) +}) diff --git a/test/playwright/e2e/search-navigation.spec.ts b/test/playwright/e2e/search-navigation.spec.ts new file mode 100644 index 0000000000..3676018c26 --- /dev/null +++ b/test/playwright/e2e/search-navigation.spec.ts @@ -0,0 +1,112 @@ +import { expect, test } from '@playwright/test' + +import { + enableNewHeader, + goToSearchTerm, + openFilters, +} from '~~/test/playwright/utils/navigation' +import { mockProviderApis } from '~~/test/playwright/utils/route' +import breakpoints from '~~/test/playwright/utils/breakpoints' + +test.describe.configure({ mode: 'parallel' }) + +test.describe('search history navigation', () => { + breakpoints.describeMobileAndDesktop(() => { + test.beforeEach(async ({ context, page }) => { + await mockProviderApis(context) + await enableNewHeader(page) + }) + + test('should update search results when back navigation changes filters', async ({ + page, + }) => { + await goToSearchTerm(page, 'galah') + // Open filter sidebar + await openFilters(page) + + // Apply a filter + await page.click('#modification') + + // Verify the filter is applied to the URL and the checkbox is checked + // Note: Need to add that a search was actually executed with the new + // filters and that the page results have been updated for the new filters + // @todo(sarayourfriend): ^? + expect(page.url()).toContain('license_type=modification') + expect(await page.isChecked('#modification')).toBe(true) + + // Navigate backwards and verify URL is updated and the filter is unapplied + await page.goBack() + + // Ditto here about the note above, need to verify a new search actually happened with new results + expect(page.url()).not.toContain('license_type=modification') + expect(await page.isChecked('#modification')).toBe(false) + }) + + test('should update search results when back button updates search type', async ({ + page, + }) => { + await goToSearchTerm(page, 'galah') + await page.click('a:has-text("See all images")') + + await page.waitForSelector('p:has-text("See all images")', { + state: 'hidden', + }) + expect(page.url()).toContain('/search/image') + await page.goBack() + await page.waitForSelector('a:has-text("See all images")') + expect( + await page.locator('a:has-text("See all images")').isVisible() + ).toBe(true) + expect( + await page.locator('a:has-text("See all audio")').isVisible() + ).toBe(true) + }) + + test('navigates to the image detail page correctly', async ({ page }) => { + await goToSearchTerm(page, 'honey') + const figure = page.locator('figure').first() + const imgTitle = await figure.locator('img').getAttribute('alt') + + await page.locator('a[href^="/image"]').first().click() + // Until the image is loaded, the heading is 'Image' instead of the actual title + await page.locator('#main-image').waitFor() + + const headingText = await page.locator('h1').textContent() + expect(headingText?.trim().toLowerCase()).toEqual(imgTitle?.toLowerCase()) + }) + + test.describe('back to search results link', () => { + test('is visible in breadcrumb when navigating to image details page and returns to the search page', async ({ + page, + }) => { + const url = '/search/?q=galah' + await page.goto(url) + await page.locator('a[href^="/image"]').first().click() + const link = page.locator('text="Back to search results"') + await expect(link).toBeVisible() + await link.click() + await expect(page).toHaveURL(url) + }) + + test('is visible in breadcrumb when navigating to localized image details page', async ({ + page, + }) => { + await page.goto('/es/search/?q=galah') + await page.locator('a[href^="/es/image"]').first().click() + await expect( + page.locator('text="Volver a los resultados de búsqueda"') + ).toBeVisible() + }) + + test('is visible in breadcrumb when navigating to localized audio details page', async ({ + page, + }) => { + await page.goto('/es/search/?q=galah') + await page.locator('a[href^="/es/audio"]').first().click() + await expect( + page.locator('text="Volver a los resultados de búsqueda"') + ).toBeVisible() + }) + }) + }) +}) diff --git a/test/playwright/e2e/search-query-client.spec.ts b/test/playwright/e2e/search-query-client.spec.ts new file mode 100644 index 0000000000..8ecf679f82 --- /dev/null +++ b/test/playwright/e2e/search-query-client.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from '@playwright/test' + +import { + changeContentType, + enableNewHeader, + goToSearchTerm, + searchFromHeader, +} from '~~/test/playwright/utils/navigation' +import { mockProviderApis } from '~~/test/playwright/utils/route' + +import breakpoints from '~~/test/playwright/utils/breakpoints' + +import { AUDIO, IMAGE } from '~/constants/media' + +/** + * When navigating to the search page on the client side: + * 1. `q` parameter is set as the search input value and url parameter. + * 2. Selecting 'audio' on homepage sets the search page path and search tab. + * 3. Selecting filters on the homepage sets the search query and url parameter. + * 4. Query parameters (filter types or filter values) that are not used for + * current media type are discarded. + * 5. Can change the `q` parameter by typing into the search input and clicking on + * the Search button. + * All of these tests test search page on the client + */ + +test.describe.configure({ mode: 'parallel' }) + +test.describe('search query on CSR', () => { + breakpoints.describeMobileAndDesktop(() => { + test.beforeEach(async ({ context, page }) => { + await mockProviderApis(context) + await enableNewHeader(page) + }) + + test('q query parameter is set as the search term', async ({ page }) => { + await goToSearchTerm(page, 'cat', { mode: 'CSR' }) + + await expect(page.locator('header input[type="search"]')).toHaveValue( + 'cat' + ) + await expect(page).toHaveURL('search/?q=cat') + }) + + test('selecting `audio` on homepage, you can search for audio', async ({ + page, + }) => { + await goToSearchTerm(page, 'cat', { searchType: AUDIO, mode: 'CSR' }) + + await expect(page.locator('header input[type="search"]')).toHaveValue( + 'cat' + ) + + await expect(page).toHaveURL('search/audio?q=cat') + }) + + test('url filter parameters not used by current mediaType are discarded', async ({ + page, + }) => { + await goToSearchTerm(page, 'cat', { + searchType: IMAGE, + query: 'category=photograph', + }) + + await changeContentType(page, 'Audio') + await expect(page).toHaveURL('/search/audio?q=cat') + }) + + test('url filter types not used by current mediaType are discarded', async ({ + page, + }) => { + await goToSearchTerm(page, 'cat', { + searchType: IMAGE, + query: 'aspect_ratio=tall', + }) + + await changeContentType(page, 'Audio') + await expect(page).toHaveURL('/search/audio?q=cat') + }) + + test('can search for a different term', async ({ page }) => { + await goToSearchTerm(page, 'cat', { searchType: IMAGE }) + + await searchFromHeader(page, 'dog') + + await expect(page).toHaveURL('/search/image?q=dog') + }) + + test('search for a different term keeps query parameters', async ({ + page, + }) => { + await goToSearchTerm(page, 'cat', { + searchType: IMAGE, + query: 'license=by&extension=jpg', + }) + await searchFromHeader(page, 'dog') + + await expect(page).toHaveURL( + '/search/image?q=dog&license=by&extension=jpg' + ) + }) + }) +}) diff --git a/test/playwright/e2e/search-query-server.spec.ts b/test/playwright/e2e/search-query-server.spec.ts new file mode 100644 index 0000000000..2f7fd3a7a9 --- /dev/null +++ b/test/playwright/e2e/search-query-server.spec.ts @@ -0,0 +1,111 @@ +import { test, expect } from '@playwright/test' + +import { + assertCheckboxStatus, + currentContentType, + enableNewHeader, + goToSearchTerm, + openFilters, +} from '~~/test/playwright/utils/navigation' +import { mockProviderApis } from '~~/test/playwright/utils/route' + +import breakpoints from '~~/test/playwright/utils/breakpoints' + +import { AUDIO, IMAGE } from '~/constants/media' + +/** + * URL is correctly converted into search state: + * 1. `q` parameter is set as the search input value + * 2. /search/?query - path is used to choose the content type + * 3. query parameters are used to set the filter data: + * 3a. One of each values for `all` content + * 3b. Several query values - several filter checkboxes + * 3c. Mature filter + * 3d. Query parameters that are not used for current media type are discarded + * All of these tests test server-generated search page, not the one generated on the client + */ + +test.describe.configure({ mode: 'parallel' }) + +test.describe('search query on SSR', () => { + breakpoints.describeMobileAndDesktop(() => { + test.beforeEach(async ({ context, page }) => { + await mockProviderApis(context) + await enableNewHeader(page) + }) + + test('q query parameter is set as the search term', async ({ page }) => { + await goToSearchTerm(page, 'cat', { + query: 'license=cc0&license_type=commercial&searchBy=creator', + }) + + const searchInput = page.locator('input[type="search"]') + await expect(searchInput).toHaveValue('cat') + // Todo: focus the input? + // await expect(searchInput).toBeFocused() + }) + + test('url path /search/ is used to select `all` search tab', async ({ + page, + }) => { + await page.goto('/search/?q=cat') + + const contentType = await currentContentType(page) + expect(contentType?.trim()).toEqual('All content') + }) + + test('url path /search/audio is used to select `audio` search tab', async ({ + page, + }) => { + await goToSearchTerm(page, 'cat', { searchType: AUDIO }) + + const contentType = await currentContentType(page) + expect(contentType?.trim()).toEqual('Audio') + }) + + test('url query to filter, all tab, one parameter per filter type', async ({ + page, + }) => { + await goToSearchTerm(page, 'cat', { + query: 'license=cc0&license_type=commercial&searchBy=creator', + }) + + await openFilters(page) + for (const checkbox of ['cc0', 'commercial', 'creator']) { + await assertCheckboxStatus(page, checkbox) + } + }) + + test('url query to filter, image tab, several filters for one filter type selected', async ({ + page, + }) => { + await goToSearchTerm(page, 'cat', { + searchType: IMAGE, + query: 'searchBy=creator&extension=jpg,png,gif,svg', + }) + await openFilters(page) + const checkboxes = ['jpeg', 'png', 'gif', 'svg'] + for (const checkbox of checkboxes) { + const forValue = checkbox === 'jpeg' ? 'jpg' : checkbox + await assertCheckboxStatus(page, checkbox, forValue) + } + }) + + test.skip('url mature query is set, and can be unchecked using the Safer Browsing popup', async ({ + page, + }) => { + await goToSearchTerm(page, 'cat', { + searchType: IMAGE, + query: 'mature=true', + }) + + await page.click('button:has-text("Safer Browsing")') + + const matureCheckbox = await page.locator('text=Show Mature Content') + await expect(matureCheckbox).toBeChecked() + + await page.click('text=Show Mature Content') + await expect(page).toHaveURL('/search/image?q=cat') + }) + }) +}) diff --git a/test/playwright/e2e/search-types.spec.ts b/test/playwright/e2e/search-types.spec.ts new file mode 100644 index 0000000000..194ab4b1ad --- /dev/null +++ b/test/playwright/e2e/search-types.spec.ts @@ -0,0 +1,152 @@ +import { test, expect, Page } from '@playwright/test' + +import { + changeContentType, + enableNewHeader, + goToSearchTerm, + searchTypePath, +} from '~~/test/playwright/utils/navigation' +import { mockProviderApis } from '~~/test/playwright/utils/route' +import breakpoints from '~~/test/playwright/utils/breakpoints' + +/** + * Using SSR: + * 1. Can open 'all content' search page, and see search results. + * 2. Can open 'image' search page, and see search results. + * 3. Can open 'audio' content type page, and see search results. + * + * On client side: + * 1. Can open 'all content' search page, and see search results. + * 2. Can open 'image' search page, and see search results. + * 3. Can open 'audio' search page, and see search results. + * 4. Can open 'image' search page from the 'all content' page. + * 5. Can open 'audio' search from the 'all content' page. + * + * Results include search meta information, media grid and Meta search form, can load more media if there are more media items. + */ + +test.describe.configure({ mode: 'parallel' }) + +const allContentConfig = { + id: 'all', + name: 'All content', + url: '/search/?q=birds', + canLoadMore: true, + metaSourceCount: 7, +} as const + +const imageConfig = { + id: 'image', + name: 'Images', + url: '/search/image?q=birds', + canLoadMore: true, + metaSourceCount: 7, + results: /Over 10,000 results/, +} as const + +const audioConfig = { + id: 'audio', + name: 'Audio', + url: '/search/audio?q=birds', + canLoadMore: true, + metaSourceCount: 3, + results: /764 results/, +} as const + +const searchTypes = [allContentConfig, imageConfig, audioConfig] as const + +type SearchTypeConfig = typeof searchTypes[number] + +async function checkLoadMore(page: Page, searchType: SearchTypeConfig) { + const loadMoreSection = page.locator('[data-testid="load-more"]') + if (!searchType.canLoadMore) { + // When we expect the section not to be here, the test becomes very slow because + // it waits until the end of the timeout (5 seconds). + await expect(loadMoreSection).toHaveCount(0, { timeout: 300 }) + } else { + await expect(loadMoreSection).toHaveCount(1) + await expect(loadMoreSection).toContainText('Load more') + } +} +async function checkMetasearchForm(page: Page, searchType: SearchTypeConfig) { + const metaSearchForm = await page.locator( + '[data-testid="external-sources-form"]' + ) + await expect(metaSearchForm).toHaveCount(1) + + const sourceButtons = await page.locator('.external-sources a') + await expect(sourceButtons).toHaveCount(searchType.metaSourceCount) +} + +async function checkSearchMetadata(page: Page, searchType: SearchTypeConfig) { + if (searchType.canLoadMore) { + const searchResult = await page.locator('[data-testid="search-results"]') + await expect(searchResult).toBeVisible() + await expect(searchResult).not.toBeEmpty() + } +} + +async function checkPageMeta(page: Page, searchType: SearchTypeConfig) { + const urlParam = searchTypePath(searchType.id) + + const expectedTitle = `birds | Openverse` + const expectedURL = `/search/${urlParam}?q=birds` + + await expect(page).toHaveTitle(expectedTitle) + await expect(page).toHaveURL(expectedURL) +} +async function checkSearchResult(page: Page, searchType: SearchTypeConfig) { + await checkSearchMetadata(page, searchType) + await checkLoadMore(page, searchType) + await checkMetasearchForm(page, searchType) + await checkPageMeta(page, searchType) +} + +test.describe('search types', () => { + breakpoints.describeMobileAndDesktop(() => { + test.beforeEach(async ({ context, page }) => { + await mockProviderApis(context) + await enableNewHeader(page) + }) + + for (const searchType of searchTypes) { + test(`Can open ${searchType.name} search page on SSR`, async ({ + page, + }) => { + await goToSearchTerm(page, 'birds', { searchType: searchType.id }) + + await checkSearchResult(page, searchType) + }) + + test(`Can open ${searchType.name} page client-side`, async ({ page }) => { + // Audio is loading a lot of files, so we do not use it for the first SSR page + const pageToOpen = + searchType.id === 'all' ? searchTypes[1] : searchTypes[0] + await page.goto(pageToOpen.url) + await changeContentType(page, searchType.name) + await checkSearchResult(page, searchType) + }) + } + + for (const searchTypeName of ['audio', 'image'] as const) { + const searchType = searchTypes.find( + (type) => type.id === searchTypeName + ) as typeof audioConfig | typeof imageConfig + test(`Can open ${searchTypeName} page from the all view`, async ({ + page, + }) => { + await page.goto('/search/?q=birds') + const contentLink = await page.locator( + `a:not([role="radio"])[href*="/search/${searchTypeName}"][href$="q=birds"]` + ) + await expect(contentLink).toContainText(searchType.results) + await page.click( + `a:not([role="radio"])[href*="/search/${searchTypeName}"][href$="q=birds"]` + ) + + await expect(page).toHaveURL(searchType.url) + await checkSearchResult(page, searchType) + }) + } + }) +}) diff --git a/test/playwright/utils/breakpoints.ts b/test/playwright/utils/breakpoints.ts index 8993bfd16e..315b282d85 100644 --- a/test/playwright/utils/breakpoints.ts +++ b/test/playwright/utils/breakpoints.ts @@ -21,14 +21,8 @@ type BreakpointBlock = (options: { expectSnapshot: ExpectSnapshot }) => void -const desktopBreakpoints = ['2xl', 'xl', 'lg', 'md'] as const -const mobileBreakpoints = ['sm', 'xs'] as const - -/** - * For e2e functionality testing, we need to test mobile and desktop screens. - */ -export const testScreens = ['sm', 'xl'] as const -export type TestScreen = typeof testScreens[number] +const desktopBreakpoints = ['2xl', 'xl', 'lg'] as const +const mobileBreakpoints = ['md', 'sm', 'xs'] as const // For desktop UA use the default const desktopUa = undefined @@ -144,21 +138,27 @@ const describeEachBreakpoint = const describeEvery = describeEachBreakpoint( Object.keys(VIEWPORTS) as Breakpoint[] ) -const describeEachDesktopWithMd = describeEachBreakpoint(desktopBreakpoints) -const describeEachDesktop = describeEachBreakpoint( - desktopBreakpoints.filter((b) => b !== 'md') -) +const describeEachDesktopWithMd = describeEachBreakpoint([ + ...desktopBreakpoints, + 'md', +]) +const describeEachDesktop = describeEachBreakpoint(desktopBreakpoints) const describeEachMobile = describeEachBreakpoint(mobileBreakpoints) -const describeEachMobileWithoutMd = describeEachBreakpoint(mobileBreakpoints) +const describeEachMobileWithoutMd = describeEachBreakpoint( + mobileBreakpoints.filter((b) => b !== 'md') +) +const describeMobileAndDesktop = describeEachBreakpoint(['sm', 'xl']) export default { ...breakpointTests, describeEachBreakpoint, describeEvery, describeEachDesktop, - // TODO: remove describeEachDesktopWithMd after the new_header is merged - describeEachDesktopWithMd, describeEachMobile, - // TODO: remove describeEachMobileWithoutMd after the new_header is merged + // For `old_header` layout and for VHeaderInternal, the mobile layout ends at `md` breakpoint describeEachMobileWithoutMd, + describeEachDesktopWithMd, + // For testing functionality in e2e tests, we need to test mobile and desktop screens. + // Having two breakpoints should be enough and should save testing time. + describeMobileAndDesktop, } diff --git a/test/playwright/visual-regression/components/audio-results-old.spec.ts b/test/playwright/visual-regression/components/audio-results-old.spec.ts index ac40104388..3cafa007a8 100644 --- a/test/playwright/visual-regression/components/audio-results-old.spec.ts +++ b/test/playwright/visual-regression/components/audio-results-old.spec.ts @@ -16,7 +16,6 @@ test.describe('audio results', () => { test('should render small row layout desktop UA with narrow viewport', async ({ page, }) => { - await closeFilters(page, OLD_HEADER) await expectSnapshot('audio-results-narrow-viewport-desktop-UA', page) }) } @@ -28,7 +27,6 @@ test.describe('audio results', () => { test('should render small row layout mobile UA with narrow viewport', async ({ page, }) => { - await closeFilters(page, OLD_HEADER) await expectSnapshot('audio-results-narrow-viewport-mobile-UA', page) }) } diff --git a/test/playwright/visual-regression/components/audio-results-old.spec.ts-snapshots/audio-results-narrow-viewport-desktop-UA-sm-linux.png b/test/playwright/visual-regression/components/audio-results-old.spec.ts-snapshots/audio-results-narrow-viewport-desktop-UA-sm-linux.png index b851b7b2e5..ec3333021e 100644 Binary files a/test/playwright/visual-regression/components/audio-results-old.spec.ts-snapshots/audio-results-narrow-viewport-desktop-UA-sm-linux.png and b/test/playwright/visual-regression/components/audio-results-old.spec.ts-snapshots/audio-results-narrow-viewport-desktop-UA-sm-linux.png differ diff --git a/test/playwright/visual-regression/components/audio-results-old.spec.ts-snapshots/audio-results-narrow-viewport-desktop-UA-xs-linux.png b/test/playwright/visual-regression/components/audio-results-old.spec.ts-snapshots/audio-results-narrow-viewport-desktop-UA-xs-linux.png index d88fcd6fce..13a60a850e 100644 Binary files a/test/playwright/visual-regression/components/audio-results-old.spec.ts-snapshots/audio-results-narrow-viewport-desktop-UA-xs-linux.png and b/test/playwright/visual-regression/components/audio-results-old.spec.ts-snapshots/audio-results-narrow-viewport-desktop-UA-xs-linux.png differ diff --git a/test/playwright/visual-regression/components/media-reuse.spec.ts b/test/playwright/visual-regression/components/media-reuse.spec.ts index 80d1472853..c8908b9fb1 100644 --- a/test/playwright/visual-regression/components/media-reuse.spec.ts +++ b/test/playwright/visual-regression/components/media-reuse.spec.ts @@ -1,12 +1,14 @@ -import { Route, test } from '@playwright/test' +import { test } from '@playwright/test' import breakpoints from '~~/test/playwright/utils/breakpoints' import { dismissTranslationBanner, - pathWithDir, languageDirections, + pathWithDir, + scrollDownAndUp, t, } from '~~/test/playwright/utils/navigation' +import { mockProviderApis } from '~~/test/playwright/utils/route' test.describe.configure({ mode: 'parallel' }) @@ -16,22 +18,24 @@ const tabs = [ { id: 'plain', name: 'Plain text' }, ] -const cancelImageRequests = (route: Route) => - route.request().resourceType() === 'image' ? route.abort() : route.continue() - test.describe('media-reuse', () => { for (const tab of tabs) { for (const dir of languageDirections) { breakpoints.describeEvery(({ expectSnapshot }) => { test(`Should render a ${dir} media reuse section with "${tab.name}" tab open`, async ({ + context, page, }) => { - await page.route('**/*', cancelImageRequests) + await mockProviderApis(context) await page.goto( pathWithDir('/image/f9384235-b72e-4f1e-9b05-e1b116262a29', dir) ) await dismissTranslationBanner(page) + // The image is loading from provider, so we need to wait for it to + // finish loaded to prevent the shift of the component during snapshots. + await scrollDownAndUp(page) + await page.waitForLoadState('networkidle') await page.locator(`#tab-${tab.id}`).click() // Make sure the tab is not focused and doesn't have a pink ring diff --git a/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-ltr-html-tab-xs-linux.png b/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-ltr-html-tab-xs-linux.png index 7bbd81dfcd..4f41edbcff 100644 Binary files a/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-ltr-html-tab-xs-linux.png and b/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-ltr-html-tab-xs-linux.png differ diff --git a/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-ltr-plain-tab-xs-linux.png b/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-ltr-plain-tab-xs-linux.png index b701ed4ba1..673006aaf0 100644 Binary files a/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-ltr-plain-tab-xs-linux.png and b/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-ltr-plain-tab-xs-linux.png differ diff --git a/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-ltr-rich-tab-xs-linux.png b/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-ltr-rich-tab-xs-linux.png index 377fa1c3a2..a6a74b739d 100644 Binary files a/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-ltr-rich-tab-xs-linux.png and b/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-ltr-rich-tab-xs-linux.png differ diff --git a/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-html-tab-md-linux.png b/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-html-tab-md-linux.png index fe88575c63..9eb458b420 100644 Binary files a/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-html-tab-md-linux.png and b/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-html-tab-md-linux.png differ diff --git a/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-html-tab-xs-linux.png b/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-html-tab-xs-linux.png index c2ef612bec..3a5dcfcea9 100644 Binary files a/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-html-tab-xs-linux.png and b/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-html-tab-xs-linux.png differ diff --git a/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-plain-tab-md-linux.png b/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-plain-tab-md-linux.png index ddaf8eee5c..f7a9e5cb9c 100644 Binary files a/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-plain-tab-md-linux.png and b/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-plain-tab-md-linux.png differ diff --git a/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-plain-tab-xs-linux.png b/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-plain-tab-xs-linux.png index 1e69264741..1d709fc205 100644 Binary files a/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-plain-tab-xs-linux.png and b/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-plain-tab-xs-linux.png differ diff --git a/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-rich-tab-md-linux.png b/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-rich-tab-md-linux.png index adb12f043d..7402731309 100644 Binary files a/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-rich-tab-md-linux.png and b/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-rich-tab-md-linux.png differ diff --git a/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-rich-tab-xs-linux.png b/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-rich-tab-xs-linux.png index 60fa2681fe..94d58a64c1 100644 Binary files a/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-rich-tab-xs-linux.png and b/test/playwright/visual-regression/components/media-reuse.spec.ts-snapshots/media-reuse-rtl-rich-tab-xs-linux.png differ diff --git a/test/storybook/visual-regression/v-header-internal.spec.ts b/test/storybook/visual-regression/v-header-internal.spec.ts index 6ba6ff447f..657b843fdb 100644 --- a/test/storybook/visual-regression/v-header-internal.spec.ts +++ b/test/storybook/visual-regression/v-header-internal.spec.ts @@ -11,7 +11,7 @@ const pageUrl = (dir: typeof languageDirections[number]) => test.describe('VHeaderInternal', () => { for (const dir of languageDirections) { - breakpoints.describeEachDesktop(({ expectSnapshot }) => { + breakpoints.describeEachDesktopWithMd(({ expectSnapshot }) => { test(`desktop-header-internal-${dir}`, async ({ page }) => { await page.goto(pageUrl(dir)) await page.mouse.move(0, 150) @@ -21,7 +21,7 @@ test.describe('VHeaderInternal', () => { ) }) }) - breakpoints.describeEachMobile(({ expectSnapshot }) => { + breakpoints.describeEachMobileWithoutMd(({ expectSnapshot }) => { test(`mobile-header-internal-${dir}`, async ({ page }) => { await page.goto(pageUrl(dir)) await page.mouse.move(0, 150)