diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index f897e0b..4294dd5 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -1,15 +1,64 @@ +import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; +// Ionic keeps tab pages it has shown in the DOM (hidden), so only look at visible elements. +const visibleTitle = (page: Page) => page.locator('ion-title').filter({ visible: true }).first(); +// The outgoing page can still be visible mid-transition, so match the content by its page name. +const visibleContent = (page: Page, pageName: string) => + page.locator('ion-content').filter({ visible: true, hasText: pageName }); + +const tabs = { + tab1: { name: 'Tab 1', url: /\/tab1$/, title: 'Tab 1' }, + photos: { name: 'Photos', url: /\/tab2$/, title: 'Photo Gallery' }, + tab3: { name: 'Tab 3', url: /\/tab3$/, title: 'Tab 3' }, +}; + test('shows the first tab on load', async ({ page }) => { await page.goto('/'); - await expect(page.locator('ion-content').getByText('Tab 1 page')).toBeVisible(); + await expect(page).toHaveURL(tabs.tab1.url); + await expect(visibleTitle(page)).toHaveText(tabs.tab1.title); + await expect(page.getByRole('tab', { name: tabs.tab1.name })).toHaveAttribute('aria-selected', 'true'); +}); + +test('switches tabs from the tab bar', async ({ page }) => { + await page.goto('/'); + + for (const tab of [tabs.photos, tabs.tab3, tabs.tab1]) { + await page.getByRole('tab', { name: tab.name }).click(); + + await expect(page).toHaveURL(tab.url); + await expect(visibleTitle(page)).toHaveText(tab.title); + await expect(page.getByRole('tab', { name: tab.name })).toHaveAttribute('aria-selected', 'true'); + } }); -test('opens the photo gallery from the tab bar', async ({ page }) => { +test('shows an icon on each tab', async ({ page }) => { await page.goto('/'); - await page.getByRole('tab', { name: 'Photos' }).click(); - await expect(page).toHaveURL(/\/tab2$/); - await expect(page.locator('ion-title', { hasText: 'Photo Gallery' }).first()).toBeVisible(); + for (const tab of [tabs.tab1, tabs.photos, tabs.tab3]) { + // ion-icon draws its SVG in its shadow root, which Playwright's locators can see. + const tabButton = page.locator('ion-tab-button').filter({ hasText: tab.name }); + await expect(tabButton.locator('ion-icon svg')).toBeVisible(); + } }); + +for (const tab of [ + { ...tabs.tab1, pageName: 'Tab 1 page' }, + { ...tabs.tab3, pageName: 'Tab 3 page' }, +]) { + test(`${tab.name} links to the Ionic UI components docs`, async ({ page }) => { + await page.goto('/'); + await page.getByRole('tab', { name: tab.name }).click(); + await expect(page).toHaveURL(tab.url); + + const content = visibleContent(page, tab.pageName); + await expect(content.getByText(tab.pageName, { exact: true })).toBeVisible(); + await expect(content).toContainText('Explore UI Components'); + + const link = content.getByRole('link', { name: 'UI Components' }); + await expect(link).toHaveAttribute('href', 'https://ionicframework.com/docs/components'); + await expect(link).toHaveAttribute('target', '_blank'); + await expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); +} diff --git a/e2e/gallery.spec.ts b/e2e/gallery.spec.ts new file mode 100644 index 0000000..f6a8333 --- /dev/null +++ b/e2e/gallery.spec.ts @@ -0,0 +1,93 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +// Chromium's fake camera stands in for a real one, so these tests run the actual +// Capacitor Camera web flow (the pwa-elements camera modal). +test.use({ + permissions: ['camera'], + launchOptions: { args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'] }, +}); + +const takePhoto = async (page: Page): Promise => { + await page.locator('ion-fab-button').click(); + await page.locator('pwa-camera .shutter').click(); + await page.locator('pwa-camera .accept-use').click(); +}; + +const photo = (page: Page, position: number) => page.getByRole('button', { name: `Photo ${position}` }); + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.getByRole('tab', { name: 'Photos' }).click(); +}); + +test('takes a photo and adds it to the gallery', async ({ page }) => { + await takePhoto(page); + + await expect(photo(page, 1)).toBeVisible(); + await expect(photo(page, 2)).toHaveCount(0); +}); + +test('keeps photos after the app reloads', async ({ page }) => { + await takePhoto(page); + await expect(photo(page, 1)).toBeVisible(); + + await page.reload(); + + await expect(page.getByRole('img', { name: 'Photo 1' })).toHaveAttribute('src', /^data:image\/jpeg;base64,/); +}); + +test('deletes a photo from the action sheet', async ({ page }) => { + await takePhoto(page); + await takePhoto(page); + await expect(photo(page, 2)).toBeVisible(); + + await photo(page, 1).click(); + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect(photo(page, 2)).toHaveCount(0); + await expect(photo(page, 1)).toBeVisible(); + + await page.reload(); + + // Wait for the remaining photo to load from storage before checking the deleted one stayed gone. + await expect(page.getByRole('img', { name: 'Photo 1' })).toHaveAttribute('src', /^data:image\/jpeg;base64,/); + await expect(photo(page, 2)).toHaveCount(0); +}); + +test('adds a new photo after deleting one', async ({ page }) => { + await takePhoto(page); + await photo(page, 1).click(); + await page.getByRole('button', { name: 'Delete' }).click(); + await expect(photo(page, 1)).toHaveCount(0); + + await takePhoto(page); + + await expect(photo(page, 1)).toBeVisible(); + await expect(photo(page, 2)).toHaveCount(0); + + await page.reload(); + + // Wait for the saved photo to load from storage before checking it's the only one. + await expect(page.getByRole('img', { name: 'Photo 1' })).toHaveAttribute('src', /^data:image\/jpeg;base64,/); + await expect(photo(page, 2)).toHaveCount(0); +}); + +test('keeps the photo when the action sheet is cancelled', async ({ page }) => { + await takePhoto(page); + await photo(page, 1).click(); + await page.getByRole('button', { name: 'Cancel' }).click(); + + await expect(page.locator('ion-action-sheet')).toBeHidden(); + await expect(photo(page, 1)).toBeVisible(); + + // The photo's actions open again after a cancel. + await photo(page, 1).click(); + await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible(); +}); + +test('shows a camera icon on the camera button', async ({ page }) => { + // ion-icon draws its SVG in its shadow root, which Playwright's locators can see. Only match + // the button's own icon, not the close icon ion-fab-button keeps in its shadow root. + await expect(page.locator('ion-fab-button > ion-icon').locator('svg')).toBeVisible(); +}); diff --git a/src/components/ExploreContainer.css b/src/components/ExploreContainer.css index e99f514..c2c47cf 100644 --- a/src/components/ExploreContainer.css +++ b/src/components/ExploreContainer.css @@ -1,4 +1,4 @@ -.container { +#container { text-align: center; position: absolute; left: 0; @@ -7,18 +7,18 @@ transform: translateY(-50%); } -.container strong { +#container strong { font-size: 20px; line-height: 26px; } -.container p { +#container p { font-size: 16px; line-height: 22px; color: #8c8c8c; margin: 0; } -.container a { +#container a { text-decoration: none; } \ No newline at end of file diff --git a/src/components/ExploreContainer.tsx b/src/components/ExploreContainer.tsx index 3b3f98d..bae1316 100644 --- a/src/components/ExploreContainer.tsx +++ b/src/components/ExploreContainer.tsx @@ -6,7 +6,7 @@ interface ContainerProps { const ExploreContainer: React.FC = ({ name }) => { return ( -
+
{name}

Explore{' '} diff --git a/src/hooks/usePhotoGallery.test.ts b/src/hooks/usePhotoGallery.test.ts new file mode 100644 index 0000000..6d0a1ad --- /dev/null +++ b/src/hooks/usePhotoGallery.test.ts @@ -0,0 +1,204 @@ +import { Camera } from '@capacitor/camera'; +import { Filesystem } from '@capacitor/filesystem'; +import { Preferences } from '@capacitor/preferences'; +import { isPlatform } from '@ionic/react'; +import type * as IonicReact from '@ionic/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { UserPhoto } from './usePhotoGallery'; +import { usePhotoGallery } from './usePhotoGallery'; + +vi.mock('@capacitor/camera', () => ({ + Camera: { getPhoto: vi.fn() }, + CameraResultType: { Uri: 'uri' }, + CameraSource: { Camera: 'CAMERA' }, +})); + +vi.mock('@capacitor/filesystem', () => ({ + Filesystem: { readFile: vi.fn(), writeFile: vi.fn(), deleteFile: vi.fn() }, + Directory: { Data: 'DATA' }, +})); + +vi.mock('@capacitor/preferences', () => ({ + Preferences: { get: vi.fn(), set: vi.fn() }, +})); + +vi.mock('@capacitor/core', () => ({ + Capacitor: { convertFileSrc: (uri: string) => `converted:${uri}` }, +})); + +vi.mock('@ionic/react', async (importOriginal) => ({ + ...(await importOriginal()), + isPlatform: vi.fn(), +})); + +const NOW = 1700000000000; + +const setHybrid = (hybrid: boolean): void => { + vi.mocked(isPlatform).mockImplementation((platform) => platform === 'hybrid' && hybrid); +}; + +// Renders the hook and waits for the saved photos to load. +const renderGallery = async (saved: UserPhoto[] | null) => { + vi.mocked(Preferences.get).mockResolvedValue({ value: saved ? JSON.stringify(saved) : null }); + const hook = renderHook(() => usePhotoGallery()); + // Let the mocked storage and file reads resolve and the hook update. + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); + expect(Preferences.get).toHaveBeenCalledWith({ key: 'photos' }); + return hook; +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.useFakeTimers({ now: NOW, toFake: ['Date'] }); + setHybrid(false); + vi.mocked(Filesystem.writeFile).mockResolvedValue({ uri: `file:///data/${NOW}.jpeg` }); + vi.mocked(Filesystem.readFile).mockImplementation(async ({ path }) => ({ data: `data-for-${path}` })); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('loading saved photos', () => { + it('loads saved photos and reads each file as base64 on the web', async () => { + const { result } = await renderGallery([{ filepath: 'a.jpeg' }, { filepath: 'b.jpeg' }]); + + await waitFor(() => + expect(result.current.photos).toEqual([ + { filepath: 'a.jpeg', webviewPath: 'data:image/jpeg;base64,data-for-a.jpeg' }, + { filepath: 'b.jpeg', webviewPath: 'data:image/jpeg;base64,data-for-b.jpeg' }, + ]), + ); + expect(Filesystem.readFile).toHaveBeenCalledWith({ path: 'a.jpeg', directory: 'DATA' }); + }); + + it('keeps the stored paths on native without reading the files', async () => { + setHybrid(true); + const saved = [{ filepath: 'file:///data/a.jpeg', webviewPath: 'converted:file:///data/a.jpeg' }]; + + const { result } = await renderGallery(saved); + + await waitFor(() => expect(result.current.photos).toEqual(saved)); + expect(Filesystem.readFile).not.toHaveBeenCalled(); + }); + + it('starts with an empty gallery when nothing is saved', async () => { + const { result } = await renderGallery(null); + + expect(result.current.photos).toEqual([]); + expect(Filesystem.readFile).not.toHaveBeenCalled(); + }); +}); + +describe('takePhoto', () => { + it('saves a web photo and adds it to the front of the gallery', async () => { + const { result } = await renderGallery([{ filepath: 'older.jpeg' }]); + await waitFor(() => expect(result.current.photos).toHaveLength(1)); + vi.mocked(Camera.getPhoto).mockResolvedValue({ webPath: 'blob:new-photo', format: 'jpeg', saved: false }); + // Return the jsdom Blob directly: Node's Response would stringify it. + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ blob: async () => new Blob(['image-bytes'], { type: 'image/jpeg' }) }), + ); + + await act(() => result.current.takePhoto()); + + expect(Camera.getPhoto).toHaveBeenCalledWith({ resultType: 'uri', source: 'CAMERA', quality: 100 }); + expect(Filesystem.writeFile).toHaveBeenCalledWith({ + path: `${NOW}.jpeg`, + data: `data:image/jpeg;base64,${btoa('image-bytes')}`, + directory: 'DATA', + }); + const expected = [ + { filepath: `${NOW}.jpeg`, webviewPath: 'blob:new-photo' }, + { filepath: 'older.jpeg', webviewPath: 'data:image/jpeg;base64,data-for-older.jpeg' }, + ]; + expect(result.current.photos).toEqual(expected); + expect(Preferences.set).toHaveBeenCalledWith({ key: 'photos', value: JSON.stringify(expected) }); + }); + + it.each([ + ['the photo cannot be read', 'error' as const], + ['the photo is not read as a data URL', new ArrayBuffer(1)], + ])('does not save a web photo when %s', async (_, outcome) => { + const { result } = await renderGallery(null); + vi.mocked(Camera.getPhoto).mockResolvedValue({ webPath: 'blob:new-photo', format: 'jpeg', saved: false }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ blob: async () => new Blob(['image-bytes']) })); + vi.stubGlobal( + 'FileReader', + class { + result: unknown = null; + onerror: ((error: unknown) => void) | null = null; + onload: (() => void) | null = null; + + readAsDataURL(): void { + if (outcome === 'error') { + this.onerror?.(new Error('read failed')); + } else { + this.result = outcome; + this.onload?.(); + } + } + }, + ); + + await expect(act(() => result.current.takePhoto())).rejects.toBeDefined(); + + expect(Filesystem.writeFile).not.toHaveBeenCalled(); + expect(result.current.photos).toEqual([]); + expect(Preferences.set).not.toHaveBeenCalled(); + }); + + it('reads a native photo from its file path and converts the saved URI', async () => { + setHybrid(true); + const { result } = await renderGallery(null); + vi.mocked(Camera.getPhoto).mockResolvedValue({ + path: 'file:///tmp/photo.jpeg', + webPath: 'capacitor://localhost/_capacitor_file_/tmp/photo.jpeg', + format: 'jpeg', + saved: false, + }); + vi.mocked(Filesystem.readFile).mockResolvedValue({ data: 'BASE64DATA' }); + + await act(() => result.current.takePhoto()); + + expect(Filesystem.readFile).toHaveBeenCalledWith({ path: 'file:///tmp/photo.jpeg' }); + expect(Filesystem.writeFile).toHaveBeenCalledWith({ path: `${NOW}.jpeg`, data: 'BASE64DATA', directory: 'DATA' }); + expect(result.current.photos).toEqual([ + { filepath: `file:///data/${NOW}.jpeg`, webviewPath: `converted:file:///data/${NOW}.jpeg` }, + ]); + }); + + it('converts native file data returned as a Blob to text', async () => { + setHybrid(true); + const { result } = await renderGallery(null); + vi.mocked(Camera.getPhoto).mockResolvedValue({ path: 'file:///tmp/photo.jpeg', format: 'jpeg', saved: false }); + vi.mocked(Filesystem.readFile).mockResolvedValue({ data: new Blob(['BLOBDATA']) }); + + await act(() => result.current.takePhoto()); + + expect(Filesystem.writeFile).toHaveBeenCalledWith(expect.objectContaining({ data: 'BLOBDATA' })); + }); +}); + +describe('deletePhoto', () => { + it('removes the photo from the gallery, storage, and filesystem', async () => { + setHybrid(true); + const saved = [ + { filepath: 'file:///data/a.jpeg' }, + { filepath: 'file:///data/b.jpeg' }, + { filepath: 'file:///data/c.jpeg' }, + ]; + const { result } = await renderGallery(saved); + await waitFor(() => expect(result.current.photos).toHaveLength(3)); + + await act(() => result.current.deletePhoto(saved[1])); + + expect(result.current.photos).toEqual([saved[0], saved[2]]); + expect(Preferences.set).toHaveBeenCalledWith({ key: 'photos', value: JSON.stringify([saved[0], saved[2]]) }); + expect(Filesystem.deleteFile).toHaveBeenCalledWith({ path: 'b.jpeg', directory: 'DATA' }); + }); +}); diff --git a/src/pages/Tab2.test.tsx b/src/pages/Tab2.test.tsx new file mode 100644 index 0000000..355f069 --- /dev/null +++ b/src/pages/Tab2.test.tsx @@ -0,0 +1,75 @@ +import type { ActionSheetButton } from '@ionic/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { close, trash } from 'ionicons/icons'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { UserPhoto } from '../hooks/usePhotoGallery'; + +import Tab2 from './Tab2'; + +const gallery = vi.hoisted(() => ({ + photos: [] as UserPhoto[], + takePhoto: vi.fn(), + deletePhoto: vi.fn(), +})); + +vi.mock('../hooks/usePhotoGallery', () => ({ + usePhotoGallery: () => gallery, +})); + +const photos: UserPhoto[] = [ + { filepath: 'a.jpeg', webviewPath: 'data:image/jpeg;base64,AAA' }, + { filepath: 'b.jpeg', webviewPath: 'data:image/jpeg;base64,BBB' }, +]; + +const actionSheet = () => document.querySelector('ion-action-sheet') as HTMLIonActionSheetElement; + +describe('Tab2', () => { + beforeEach(() => { + vi.resetAllMocks(); + gallery.photos = photos; + }); + + it('shows a thumbnail for each photo', () => { + render(); + + expect(screen.getByAltText('Photo 1')).toHaveAttribute('src', photos[0].webviewPath); + expect(screen.getByAltText('Photo 2')).toHaveAttribute('src', photos[1].webviewPath); + }); + + it('takes a photo when the camera button is tapped', () => { + const { container } = render(); + + fireEvent.click(container.querySelector('ion-fab-button') as Element); + + expect(gallery.takePhoto).toHaveBeenCalledTimes(1); + }); + + it('opens the photo actions when a photo is tapped', async () => { + render(); + expect(actionSheet().isOpen).toBe(false); + + fireEvent.click(screen.getByAltText('Photo 2')); + + await waitFor(() => expect(actionSheet().isOpen).toBe(true)); + expect(actionSheet().buttons).toEqual([ + expect.objectContaining({ text: 'Delete', role: 'destructive', icon: trash }), + expect.objectContaining({ text: 'Cancel', role: 'cancel', icon: close }), + ]); + }); + + it('deletes the tapped photo when Delete is chosen', async () => { + render(); + fireEvent.click(screen.getByAltText('Photo 2')); + await waitFor(() => expect(actionSheet().isOpen).toBe(true)); + + const deleteButton = (actionSheet().buttons as ActionSheetButton[]).find((button) => button.text === 'Delete'); + expect(deleteButton?.handler).toBeTypeOf('function'); + act(() => { + deleteButton?.handler?.(); + }); + + expect(gallery.deletePhoto).toHaveBeenCalledWith(photos[1]); + await waitFor(() => expect(actionSheet().isOpen).toBe(false)); + }); +});