Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 54 additions & 5 deletions e2e/app.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
}
93 changes: 93 additions & 0 deletions e2e/gallery.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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();
});
16 changes: 12 additions & 4 deletions src/app/explore-container/explore-container.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { TestBed } from '@angular/core/testing';
import { ExploreContainerComponent } from './explore-container.component';

describe('ExploreContainerComponent', () => {
let component: ExploreContainerComponent;
let fixture: ComponentFixture<ExploreContainerComponent>;

beforeEach(async () => {
Expand All @@ -15,11 +14,20 @@ describe('ExploreContainerComponent', () => {
}).compileComponents();

fixture = TestBed.createComponent(ExploreContainerComponent);
component = fixture.componentInstance;
});

it('shows the page name', () => {
fixture.componentRef.setInput('name', 'Tab 1 page');
fixture.detectChanges();

expect(fixture.nativeElement.querySelector('strong').textContent).toBe('Tab 1 page');
});

it('should create', () => {
expect(component).toBeTruthy();
it('links to the Ionic UI components docs', () => {
fixture.detectChanges();

const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a');
expect(link.href).toBe('https://ionicframework.com/docs/components');
expect(link.target).toBe('_blank');
});
});
168 changes: 165 additions & 3 deletions src/app/services/photo.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,182 @@
import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideIonicAngular } from '@ionic/angular';
import { Camera } from '@capacitor/camera';
import { Filesystem } from '@capacitor/filesystem';
import { Preferences } from '@capacitor/preferences';
import { Platform, provideIonicAngular } from '@ionic/angular';

import type { UserPhoto } from './photo.service';
import { PhotoService } from './photo.service';

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}` },
}));

const NOW = 1700000000000;

describe('PhotoService', () => {
let service: PhotoService;
let isHybrid: boolean;

beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(Date, 'now').mockReturnValue(NOW);
vi.mocked(Filesystem.writeFile).mockResolvedValue({ uri: `file:///data/${NOW}.jpeg` });

TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection(), provideIonicAngular()],
});
service = TestBed.inject(PhotoService);

isHybrid = false;
vi.spyOn(TestBed.inject(Platform), 'is').mockImplementation((platform) => platform === 'hybrid' && isHybrid);
});

it('should be created', () => {
expect(service).toBeTruthy();
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe('addNewToGallery', () => {
it('saves a web photo and adds it to the front of the gallery', async () => {
const existing: UserPhoto = { filepath: 'older.jpeg', webviewPath: 'blob:older' };
service.photos.set([existing]);
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 service.addNewToGallery();

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',
});
expect(service.photos()).toEqual([{ filepath: `${NOW}.jpeg`, webviewPath: 'blob:new-photo' }, existing]);
expect(Preferences.set).toHaveBeenCalledWith({ key: 'photos', value: JSON.stringify(service.photos()) });
});

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) => {
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(service.addNewToGallery()).rejects.toBeDefined();

expect(Filesystem.writeFile).not.toHaveBeenCalled();
expect(service.photos()).toEqual([]);
expect(Preferences.set).not.toHaveBeenCalled();
});

it('reads a native photo from its file path and converts the saved URI', async () => {
isHybrid = true;
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 service.addNewToGallery();

expect(Filesystem.readFile).toHaveBeenCalledWith({ path: 'file:///tmp/photo.jpeg' });
expect(Filesystem.writeFile).toHaveBeenCalledWith({ path: `${NOW}.jpeg`, data: 'BASE64DATA', directory: 'DATA' });
expect(service.photos()).toEqual([
{ filepath: `file:///data/${NOW}.jpeg`, webviewPath: `converted:file:///data/${NOW}.jpeg` },
]);
});
});

describe('loadSaved', () => {
it('loads saved photos and reads each file as base64 on the web', async () => {
vi.mocked(Preferences.get).mockResolvedValue({
value: JSON.stringify([{ filepath: 'a.jpeg' }, { filepath: 'b.jpeg' }]),
});
vi.mocked(Filesystem.readFile).mockImplementation(async ({ path }) => ({ data: `data-for-${path}` }));

await service.loadSaved();

expect(Preferences.get).toHaveBeenCalledWith({ key: 'photos' });
expect(Filesystem.readFile).toHaveBeenCalledWith({ path: 'a.jpeg', directory: 'DATA' });
expect(service.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' },
]);
});

it('keeps the stored paths on native without reading the files', async () => {
isHybrid = true;
const saved: UserPhoto[] = [{ filepath: 'file:///data/a.jpeg', webviewPath: 'converted:file:///data/a.jpeg' }];
vi.mocked(Preferences.get).mockResolvedValue({ value: JSON.stringify(saved) });

await service.loadSaved();

expect(Filesystem.readFile).not.toHaveBeenCalled();
expect(service.photos()).toEqual(saved);
});

it('starts with an empty gallery when nothing is saved', async () => {
vi.mocked(Preferences.get).mockResolvedValue({ value: null });

await service.loadSaved();

expect(service.photos()).toEqual([]);
});
});

describe('deletePhoto', () => {
it('removes the photo from the gallery, storage, and filesystem', async () => {
const photos: UserPhoto[] = [
{ filepath: 'file:///data/a.jpeg' },
{ filepath: 'file:///data/b.jpeg' },
{ filepath: 'file:///data/c.jpeg' },
];
service.photos.set(photos);

await service.deletePhoto(photos[1], 1);

expect(service.photos()).toEqual([photos[0], photos[2]]);
expect(Preferences.set).toHaveBeenCalledWith({ key: 'photos', value: JSON.stringify([photos[0], photos[2]]) });
expect(Filesystem.deleteFile).toHaveBeenCalledWith({ path: 'b.jpeg', directory: 'DATA' });
});
});
});
Loading
Loading