Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Viewer e2e tests #1120

Merged
merged 3 commits into from
Oct 16, 2024
Merged
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
5 changes: 5 additions & 0 deletions frontend/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const config: PlaywrightTestConfig = {
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
timeout: testEnv.TEST_TIMEOUT,
expect: {
timeout: testEnv.EXPECT_TIMEOUT,
},
/* Retry on CI only */
retries: process.env.CI ? 1 : 0,
/* Opt out of parallel tests on CI. */
Expand All @@ -37,6 +40,8 @@ const config: PlaywrightTestConfig = {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: testEnv.serverBaseUrl,

actionTimeout: testEnv.ACTION_TIMEOUT,

/* Local storage to be populated for every test */
storageState:
{
Expand Down
2 changes: 2 additions & 0 deletions frontend/tests/envVars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export const invalidJwt = 'eyJhbGciOiJIUzI1NiJ9.eyJSb2xlIjoiQWRtaW4iLCJJc3N1ZXIi

export const TEST_TIMEOUT = 40_000;
export const TEST_TIMEOUT_2X = TEST_TIMEOUT * 2;
export const ACTION_TIMEOUT = 5_000;
export const EXPECT_TIMEOUT = 5_000;

export enum HgProtocol {
Hgweb,
Expand Down
13 changes: 7 additions & 6 deletions frontend/tests/pages/basePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,18 @@ function regexEscape(s: string): string {
}

export class BasePage {
readonly page: Page;
protected url?: string;
readonly locators: Locator[];

protected get locatorTimeout(): number | undefined {
return undefined;
}

get urlPattern(): RegExp | undefined {
if (!this.url) return undefined;
return new RegExp(regexEscape(this.url) + '($|\\?|#)');
}

constructor(page: Page, locator: Locator | Locator[], url?: string) {
this.page = page;
this.url = url;
constructor(readonly page: Page, locator: Locator | Locator[], protected url?: string) {
if (Array.isArray(locator)) {
this.locators = locator;
} else {
Expand Down Expand Up @@ -55,7 +56,7 @@ export class BasePage {
await this.page.waitForURL(this.urlPattern, {waitUntil: 'load'});
}
await BasePage.waitForHydration(this.page); // wait for, e.g., onclick handlers to be attached
await Promise.all(this.locators.map(l => expect(l).toBeVisible()));
await Promise.all(this.locators.map(l => expect(l).toBeVisible({ timeout: this.locatorTimeout })));
return this;
}

Expand Down
11 changes: 10 additions & 1 deletion frontend/tests/pages/projectPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import { AddMemberModal } from '../components/addMemberModal';
import { BasePage } from './basePage';
import { DeleteProjectModal } from '../components/deleteProjectModal';
import { ResetProjectModal } from '../components/resetProjectModal';
import { ViewerPage } from './viewerPage';

export class ProjectPage extends BasePage {
get moreSettingsDiv(): Locator { return this.page.locator('.collapse').filter({ hasText: 'More settings' }); }
get deleteProjectButton(): Locator { return this.moreSettingsDiv.getByRole('button', {name: 'Delete project'}); }
get resetProjectButton(): Locator { return this.moreSettingsDiv.getByRole('button', {name: 'Reset project'}); }
get verifyRepoButton(): Locator { return this.moreSettingsDiv.getByRole('button', {name: 'Verify repository'}); }
get addMemberButton(): Locator { return this.page.getByRole('button', {name: 'Add/Invite Member'}); }
get browseButton(): Locator { return this.page.getByRole('link', {name: 'Browse'}); }

constructor(page: Page, name: string, code: string) {
constructor(page: Page, private name: string, private code: string) {
super(page, page.getByRole('heading', {name: `Project: ${name}`}), `/project/${code}`);
}

Expand Down Expand Up @@ -41,4 +43,11 @@ export class ProjectPage extends BasePage {
await this.openMoreSettings();
await this.verifyRepoButton.click();
}

async clickBrowseInViewer(): Promise<ViewerPage> {
const viewerTabPromise = this.page.context().waitForEvent('page')
await this.browseButton.click();
const viewerTab = await viewerTabPromise;
return new ViewerPage(viewerTab, this.name, this.code).waitFor();
}
}
49 changes: 49 additions & 0 deletions frontend/tests/pages/viewerPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Locator, Page } from '@playwright/test';

import { BasePage } from './basePage';

export class ViewerPage extends BasePage {

protected override get locatorTimeout(): undefined | number {
return 10_000; // the viewer can take a while to load a project
}

get entryListItems(): Locator {
return this.page.locator('.entry-list .entry');
}

get searchInputButton(): Locator {
return this.page.locator('.AppBar')
.getByRole('button').filter({ hasText: 'Find entry...' });
}

get searchResults(): Locator {
return this.page.locator('.Dialog')
.locator('.ListItem');
}

get entryDictionaryPreview(): Locator {
return this.page.locator('.fancy-border');
}

constructor(page: Page, name: string, code: string) {
super(page, page.getByRole('heading', { name }), `/project/${code}/viewer`);
}

async dismissAboutDialog(): Promise<void> {
await this.page.locator('.Dialog', { hasText: 'What is this?' })
.getByRole('button', { name: 'Close' })
.click();
}

async search(search: string): Promise<void> {
await this.searchInputButton.click();
await this.page.locator('.Dialog').getByRole('textbox').fill(search);
}

async clickSearchResult(result: string): Promise<void> {
await this.searchResults
.filter({ hasText: result })
.click();
}
}
97 changes: 97 additions & 0 deletions frontend/tests/viewerPage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import * as testEnv from './envVars';

import { UserDashboardPage } from './pages/userDashboardPage';
import { ViewerPage } from './pages/viewerPage';
import { expect } from '@playwright/test';
import { loginAs } from './utils/authHelpers';
import { test } from './fixtures';

test('navigate to viewer', async ({ page }) => {
// Step 1: Login
await loginAs(page.request, 'editor', testEnv.defaultPassword);
const userDashboard = await new UserDashboardPage(page).goto();

// Step 2: Click through to viewer
const projectPage = await userDashboard.openProject('Sena 3', 'sena-3');
const viewerPage = await projectPage.clickBrowseInViewer();
await viewerPage.dismissAboutDialog();
});

test('find entry', async ({ page }) => {
// Step 1: Login to viewer
await loginAs(page.request, 'editor', testEnv.defaultPassword);
const viewerPage = await new ViewerPage(page, 'Sena 3', 'sena-3').goto();
await viewerPage.dismissAboutDialog();

// Step 2: Use search bar to go to entry "shrew"
await viewerPage.search('animal');
await expect(viewerPage.searchResults).toHaveCount(5);
await viewerPage.clickSearchResult('shrew');

async function verifyViewerState(): Promise<void> {
// Step 3: Check that we are on the shrew entry
await expect(viewerPage.entryDictionaryPreview).toContainText('shrew');
await expect(viewerPage.page.locator('.field:has-text("Citation form") input:visible'))
.toHaveValue('nkhwizi');

// Step 4: Verify entry list filter and order
await expect(viewerPage.entryListItems).toHaveCount(7);
await expect(viewerPage.entryListItems.nth(0)).toContainText('cifuwo');
await expect(viewerPage.entryListItems.nth(3)).toContainText('shrew');
await expect(viewerPage.entryListItems.nth(6)).toContainText('nyama');
}

await verifyViewerState();
await viewerPage.page.reload();
await viewerPage.waitFor();
await verifyViewerState();
});

test('entry details', async ({ page }) => {
// Step 1: Login to viewer at entry "thembe"
await loginAs(page.request, 'editor', testEnv.defaultPassword);
const viewerPage = await new ViewerPage(page, 'Sena 3', 'sena-3')
.goto({ urlEnd: '?entryId=49cc9257-90c7-4fe0-a9e0-2c8d72aa5e2b&search=animal' });
await viewerPage.dismissAboutDialog();

// Step 2: verify entry details
// -- Dictionary Preview
const expectPreview = expect(viewerPage.entryDictionaryPreview);
await expectPreview.toContainText('nthembe');
await expectPreview.toContainText(' N. ');
await expectPreview.toContainText(' Eng ');
await expectPreview.toContainText('animal skin alone, after it is taken off the body');
await expectPreview.toContainText(' Por ');
await expectPreview.toContainText('pele de animal');

// -- Entry fields
const lexeme = viewerPage.page.locator('.field:has-text("Lexeme") .ws-field-wrapper:visible');
await expect(lexeme.getByRole('textbox')).toHaveValue('thembe');
await expect(lexeme.getByLabel('Sen', { exact: true })).toHaveValue('thembe');

await expect(viewerPage.page.locator('.field:has-text("Citation form") input:visible')).toHaveValue('nthembe');

// -- Sense
await expect(viewerPage.page.locator('[id^="sense"]')).toHaveCount(1);
await expect(viewerPage.page.locator(':text("gloss")')).toHaveCount(1);

const gloss = viewerPage.page.locator('.field:has-text("Gloss") .ws-field-wrapper:visible');
await expect(gloss).toHaveCount(2);
await expect(gloss.getByLabel('Eng')).toHaveValue('animal skin');
await expect(gloss.getByLabel('Por')).toHaveValue('pele de animal');

const definition = viewerPage.page.locator('.field:has-text("Definition") .ws-field-wrapper:visible');
await expect(definition).toHaveCount(1);
await expect(definition.getByLabel('Eng')).toHaveValue('animal skin alone, after it is taken off the body');

const grammaticalInfo = viewerPage.page.locator('.field:has-text("Grammatical info") input:visible');
await expect(grammaticalInfo).toHaveValue('Nome');

const semanticDomains = viewerPage.page.locator('.field:has-text("Semantic domain") input:visible');
await expect(semanticDomains).toHaveValue('1.6.2 Parts of an animal');

// -- Example
await expect(viewerPage.page.locator('[id^="example"]')).toHaveCount(1);
const reference = viewerPage.page.locator('.field:has-text("Reference") input');
await expect(reference).toHaveValue('Wordlist');
});
5 changes: 4 additions & 1 deletion frontend/viewer/src/ProjectView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,10 @@
</div>
{:else}
<div class="project-view !flex flex-col PortalTarget" style="{spaceForEditorStyle}">
<AppBar title={projectName} class="bg-secondary min-h-12 shadow-md">
<AppBar class="bg-secondary min-h-12 shadow-md">
<div slot="title" class="prose">
<h3>{projectName}</h3>
</div>
<Button
classes={{root: showHomeButton ? '' : 'hidden'}}
slot="menuIcon"
Expand Down
27 changes: 14 additions & 13 deletions frontend/viewer/src/lib/search-bar/SearchBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,20 @@
}
</script>

<Field
classes={{ input: 'my-1 justify-center opacity-60' }}
on:click={() => (showSearchDialog = true)}
class="cursor-pointer opacity-80 hover:opacity-100">
<div class="hidden lg:contents">
Find entry...
<span class="ml-2"><Icon data={mdiMagnify} /></span>
<span class="ml-4"><span class="key">Shift</span>+<span class="key">Shift</span></span>
</div>
<div class="contents lg:hidden">
<Icon data={mdiBookSearchOutline} />
</div>
</Field>
<button class="w-full cursor-pointer opacity-80 hover:opacity-100" on:click={() => (showSearchDialog = true)}>
<Field
classes={{ input: 'my-1 justify-center opacity-60' }}
class="cursor-pointer">
<div class="hidden lg:contents">
Find entry...
<span class="ml-2"><Icon data={mdiMagnify} /></span>
<span class="ml-4"><span class="key">Shift</span>+<span class="key">Shift</span></span>
</div>
<div class="contents lg:hidden">
<Icon data={mdiBookSearchOutline} />
</div>
</Field>
</button>

<Dialog bind:open={showSearchDialog} on:close={() => $search = ''} class="w-[700px]" classes={{root: 'items-start', title: 'p-2'}}>
<div slot="title">
Expand Down
Loading