Skip to content
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
2 changes: 2 additions & 0 deletions .github/workflows/new_weekly_regression_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ on:
- All
- Login
- Navbar
- Profile
- Search
- User
browser:
Expand Down Expand Up @@ -169,6 +170,7 @@ jobs:
case "${{ needs.set_variables.outputs.service }}" in
Login) echo "path=tests/login.spec.ts" >> "$GITHUB_OUTPUT" ;;
Navbar) echo "path=tests/navbar.spec.ts" >> "$GITHUB_OUTPUT" ;;
Profile) echo "path=tests/profile.spec.ts" >> "$GITHUB_OUTPUT" ;;
Search) echo "path=tests/search.spec.ts" >> "$GITHUB_OUTPUT" ;;
User) echo "path=tests/user.spec.ts" >> "$GITHUB_OUTPUT" ;;
*) echo "path=tests/" >> "$GITHUB_OUTPUT" ;;
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/single_test_runner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ on:
options:
- login.spec.ts
- navbar.spec.ts
- profile.spec.ts
- search.spec.ts
- user.spec.ts
browser:
Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ src/
tests/
login.spec.ts # port of tests/test_login.py
user.spec.ts # port of tests/test_user.py
profile.spec.ts # port of tests/test_profile.py
search.spec.ts # port of tests/test_search.py
navbar.spec.ts # port of tests/test_navbar.py
```
Expand All @@ -114,7 +115,7 @@ tick it in **both** files.
|---|---------|-------------------|-----------|--------|
| 1 | Login | `tests/test_login.py` | `tests/login.spec.ts` | [x] Migrated |
| 2 | User settings | `tests/test_user.py` | `tests/user.spec.ts` | [x] Migrated |
| 3 | Profile | `tests/test_profile.py` | | [ ] Not migrated |
| 3 | Profile | `tests/test_profile.py` | `tests/profile.spec.ts` | [x] Migrated |
| 4 | Search | `tests/test_search.py` | `tests/search.spec.ts` | [x] Migrated |
| 5 | Navbar | `tests/test_navbar.py` | `tests/navbar.spec.ts` | [x] Migrated |
| 6 | Dashboard | `tests/test_dashboard.py` | — | [ ] Not migrated |
Expand All @@ -130,7 +131,7 @@ tick it in **both** files.
| 16 | Registration user permissions | `tests/test_registration_user_permissions.py` | — | [ ] Not migrated |
| 17 | Registries | `tests/test_registries.py` | — | [ ] Not migrated |

Progress: **4 / 17** sections migrated.
Progress: **5 / 17** sections migrated.

## Notable differences from the Python suite

Expand Down Expand Up @@ -174,5 +175,15 @@ Progress: **4 / 17** sections migrated.
functions called from each `test.describe` block, since Playwright has no
class-inheritance equivalent. See `CLAUDE.md`'s "Known-flaky backend endpoints"
section for a residual click-timing flake on the dropdown items.
- **Profile**: `pages/profile.py`'s `ProfilePage` (tab/filter/sort checks reused from
`search.spec.ts`'s port of `pages/search.py`) and `SearchPageHelpers` are two
separate Python objects bound to the same `driver`; since `SearchPage.ts` already
carries all the `checkFilteringBy*`/tab-link locators, `src/pages/ProfilePage.ts`
just extends it instead of re-implementing or duplicating them, so one
`ProfilePage` instance covers both roles. `tests/test_profile.py`'s own
`_validate_project_card`/etc. module-local helpers (near-identical to
`search.spec.ts`'s `verify*SearchCard` functions) are likewise duplicated locally
in `tests/profile.spec.ts` rather than imported across spec files, matching how
the Python source itself duplicates them per test module.

## Next steps
33 changes: 33 additions & 0 deletions src/api/osfApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,3 +636,36 @@ export async function updateUserEducation(
},
});
}

/** Port of `update_user_social` - clears every social field back out for the given user. */
export async function updateUserSocial(session: OsfSession, userName: string): Promise<void> {
const userGuid = await getUserGuid(session, userName);
await session.patch(`/v2/users/${userGuid}/`, {
data: {
id: userGuid,
type: 'users',
attributes: {
social: {
researcherId: '',
linkedIn: [],
twitter: [],
github: [],
impactStory: '',
scholar: '',
profileWebsites: [],
baiduScholar: '',
researchGate: '',
ssrn: '',
academiaInstitution: '',
academiaProfileID: '',
},
},
},
});
}

/** Port of `get_user_details`. */
export async function getUserDetails(session: OsfSession, userName: string): Promise<any> {
const userGuid = await getUserGuid(session, userName);
return session.get(`/v2/users/${userGuid}/`);
}
11 changes: 10 additions & 1 deletion src/pages/PreprintPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,17 @@ export class PreprintPage extends BasePage {
return this.page.locator('a.custom-light-hover.dark-blue-link');
}

/**
* The Angular preprint-detail page has no "Submitted:" text anywhere any more -
* verified live via `tests/_debug_inspect.spec.ts` per CLAUDE.md. The equivalent
* field is now "Created: {date}" inside the file section, alongside a separate
* "Last edited : {date}" span it must not also match.
*/
get dateCreated(): Locator {
return this.page.locator('span').filter({ hasText: /^\s*Submitted/ });
return this.page
.locator('osf-preprint-file-section span')
.filter({ hasText: /^\s*Created:/ })
.first();
}

get allContributors(): Locator {
Expand Down
130 changes: 130 additions & 0 deletions src/pages/ProfilePage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { Locator } from '@playwright/test';

import * as settings from '../../config/settings';
import { waitUntilPageReady } from '../utils';
import { SearchPage } from './SearchPage';

/**
* Port of `pages/profile.py`'s `ProfilePage` - the signed-in user's own profile page
* (`/profile/`, which redirects to their canonical `/<guid>/` profile). Extends
* `SearchPage` rather than `BasePage` because the profile page renders the exact same
* `osf-search-results-container` tabs/filters/results component the main search page
* does - the `checkFilteringBy*`/tab-link getters ported there apply unchanged here,
* so there is no need to re-implement or duplicate them (`tests/profile.spec.ts` uses
* them directly against a page navigated to `/profile/` instead of `/search/`).
*/
export class ProfilePage extends SearchPage {
get url(): string {
return `${settings.OSF_HOME}/profile/`;
}

/** Port of `goto_short()` - navigates without asserting page structure. */
async gotoShort(): Promise<this> {
await this.page.goto(this.url);
await waitUntilPageReady(this.page);
return this;
}

get identity(): Locator {
return this.page.locator('osf-profile-information');
}

get profileName(): Locator {
return this.page.locator('osf-profile-information h1');
}

/**
* `p.font-normal` (the original port of this locator) also matches unrelated
* PrimeNG accordion-header paragraphs elsewhere on the page - verified live via
* `tests/_debug_inspect.spec.ts` per `CLAUDE.md`'s "verify against the live DOM"
* rule. The visible "Member since: ..." text is the actual unique, user-facing
* anchor.
*/
get profileCreatedDate(): Locator {
return this.page.getByText(/Member since:/);
}

get profileLink(): Locator {
return this.page.locator('a.dark-blue-two-link.font-bold');
}

get linkedInInput(): Locator {
return this.page.getByPlaceholder('in/userID, profie/view?profileID, or pub/pubID');
}

async clickOnButton(buttonName: string): Promise<void> {
await this.page.getByRole('button', { name: buttonName }).click();
}

async selectProfileTab(tabName: string): Promise<void> {
await this.page.getByRole('tab', { name: tabName, exact: true }).click();
}

async sendSocialLinkInput(linkId: string, placeholderText: string): Promise<void> {
await this.page.getByPlaceholder(placeholderText).fill(linkId);
}

/**
* Port of `send_social_link_input_profile_id`. These five fields all share the same
* `placeholder="profileID"` - the Python source disambiguates purely by DOM order,
* preserved here via `.nth()`. `exact: true` is required: the Academia field further
* down the form uses `placeholder="profileId"` (lowercase `d`), and Playwright's
* `getByPlaceholder` substring-matches case-insensitively by default, which without
* `exact` pulls that (and other false positives) into the match set - verified live
* via `tests/_debug_inspect.spec.ts` per `CLAUDE.md`'s "verify against the live DOM"
* rule.
*/
async sendSocialLinkInputProfileId(linkName: string, linkId: string): Promise<void> {
const indexByLinkName: Record<string, number> = {
impactstory: 0,
googlescholar: 1,
researchgate: 2,
baiduscholar: 3,
ssrn: 4,
};
const index = indexByLinkName[linkName];
if (index === undefined) return;
await this.page.getByPlaceholder('profileID', { exact: true }).nth(index).fill(linkId);
}

/** Port of `get_social_link_logo`. Returns the logo `src` for a given social link, or `null` if not found. */
async getSocialLinkLogo(socialLink: string): Promise<string | null> {
const links = this.page.locator('a.cursor-pointer.custom-light-hover img');
const stripSpaces = ['googlescholar', 'baiduscholar', 'yourwebsite'].includes(socialLink);
const count = await links.count();
for (let i = 0; i < count; i += 1) {
const altRaw = (await links.nth(i).getAttribute('alt')) ?? '';
const linkName = stripSpaces ? altRaw.replace(/ /g, '') : altRaw;
if (linkName.trim().toLowerCase() === socialLink) {
return links.nth(i).getAttribute('src');
}
}
return null;
}

/**
* Port of `click_on_save_button`. The Python source disambiguates the four
* (Name/Social/Employment/Education) "Save" buttons by index into raw DOM order,
* because Selenium's `find_elements` sees every one of them regardless of which
* tab is active. `getByRole('button', ...)` only considers elements exposed to the
* accessibility tree, which - verified live via `tests/_debug_inspect.spec.ts` -
* already excludes the inactive tabs' buttons, leaving just the one for whichever
* tab is currently selected. No index needed; `tabName` is kept for call-site
* clarity/parity with the Python signature.
*/
async clickOnSaveButton(tabName: 'Name' | 'Social' | 'Employment' | 'Education'): Promise<void> {
void tabName;
// Save re-routes the SPA to `/settings/profile?tab=N` on success rather than
// reloading, so `waitUntilPageReady`'s readyState/spinner checks alone don't
// reliably observe it finishing - verified live via `tests/_debug_inspect.spec.ts`
// that navigating away immediately after the click can race the PATCH and read
// back stale data. Wait for the PATCH itself instead.
await Promise.all([
this.page.waitForResponse(
(response) =>
/\/v2\/users\/[^/]+\/$/.test(response.url()) && response.request().method() === 'PATCH'
),
this.page.getByRole('button', { name: 'Save' }).click(),
]);
}
}
15 changes: 13 additions & 2 deletions src/pages/SearchPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,15 @@
return this.searchResults.first().locator('h2 a');
}

/**
* `div[osfstoppropagation]` (the original port of this locator) no longer uniquely
* identifies the card's type badge - verified live via `tests/_debug_inspect.spec.ts`
* per `CLAUDE.md`'s "verify against the live DOM" rule, it now also matches every
* PrimeNG accordion-header div on the page. Reuse `firstCardObjectTypeLabel`, the
* locator `search.spec.ts` already uses successfully for this same type-badge text.
*/
get nodeType(): Locator {
return this.page.locator('div[osfstoppropagation]');
return this.firstCardObjectTypeLabel;
}

get searchResults(): Locator {
Expand Down Expand Up @@ -372,9 +379,13 @@
const resultCountAfterFilterApplying = await this.getResultsCount();
expect(resultCountAfterFilterApplying).toBeLessThanOrEqual(numberOfRecords as number);
const popup = await clickExpectingPopup(this.page, this.firstSearchResultTitle);
// A subject can appear in more than one taxonomy path, so the Subjects
// section can render the same tag label twice (e.g. two "Life Sciences"
// chips) - .first() avoids a strict-mode violation on the duplicate.
const subjectLocator = popup
.locator(':is(div, section):has(> h3:text-is("Subjects"))')
.locator('span', { hasText: nameOfRecord });
.locator('span', { hasText: nameOfRecord })
.first();
// Same subject-taxonomy slowness as the filter dropdown above - the popup's
// Subjects section shows a skeleton loader before the real tags populate.
await expect(subjectLocator).toBeVisible({ timeout: 35000 });
Expand Down Expand Up @@ -411,7 +422,7 @@
await this.optionCheckboxByIndex(recordIndex).click({ force: true });
await this.waitForResultsLoad();
const resultCountAfterFilterApplying = await this.getResultsCount();
expect(resultCountAfterFilterApplying).toBeLessThanOrEqual(numberOfRecords as number);

Check failure on line 425 in src/pages/SearchPage.ts

View workflow job for this annotation

GitHub Actions / Single Test File Runner (chromium)

[chromium] › tests/search.spec.ts:662:5 › Search Page › All Tab › filtering by institution on all tab @smoke @core

1) [chromium] › tests/search.spec.ts:662:5 › Search Page › All Tab › filtering by institution on all tab @smoke @core Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(received).toBeLessThanOrEqual(expected) Expected: <= 236 Received: 237 at ../src/pages/SearchPage.ts:425 423 | await this.waitForResultsLoad(); 424 | const resultCountAfterFilterApplying = await this.getResultsCount(); > 425 | expect(resultCountAfterFilterApplying).toBeLessThanOrEqual(numberOfRecords as number); | ^ 426 | } 427 | 428 | async checkFilteringByProvider(indexOfRecordInList = '1'): Promise<void> { at SearchPage.checkFilteringByInstitution (/home/runner/work/angular-osf-playwright/angular-osf-playwright/src/pages/SearchPage.ts:425:44) at /home/runner/work/angular-osf-playwright/angular-osf-playwright/tests/search.spec.ts:663:7

Check failure on line 425 in src/pages/SearchPage.ts

View workflow job for this annotation

GitHub Actions / Single Test File Runner (chromium)

[chromium] › tests/search.spec.ts:662:5 › Search Page › All Tab › filtering by institution on all tab @smoke @core

1) [chromium] › tests/search.spec.ts:662:5 › Search Page › All Tab › filtering by institution on all tab @smoke @core Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(received).toBeLessThanOrEqual(expected) Expected: <= 236 Received: 237 at ../src/pages/SearchPage.ts:425 423 | await this.waitForResultsLoad(); 424 | const resultCountAfterFilterApplying = await this.getResultsCount(); > 425 | expect(resultCountAfterFilterApplying).toBeLessThanOrEqual(numberOfRecords as number); | ^ 426 | } 427 | 428 | async checkFilteringByProvider(indexOfRecordInList = '1'): Promise<void> { at SearchPage.checkFilteringByInstitution (/home/runner/work/angular-osf-playwright/angular-osf-playwright/src/pages/SearchPage.ts:425:44) at /home/runner/work/angular-osf-playwright/angular-osf-playwright/tests/search.spec.ts:663:7
}

async checkFilteringByProvider(indexOfRecordInList = '1'): Promise<void> {
Expand Down Expand Up @@ -594,7 +605,7 @@
this.assertSorting(dates, 'descending');

await this.sortByButton.click();
await this.sortByDateCreatedOldest.click();

Check failure on line 608 in src/pages/SearchPage.ts

View workflow job for this annotation

GitHub Actions / Single Test File Runner (chromium)

[chromium] › tests/profile.spec.ts:878:3 › Profile Page Registrations Tab › sorting by created date on registrations tab

2) [chromium] › tests/profile.spec.ts:878:3 › Profile Page Registrations Tab › sorting by created date on registrations tab Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── TimeoutError: locator.click: Timeout 25000ms exceeded. Call log: - waiting for getByRole('option', { name: 'Date created (oldest)' }) - locator resolved to <li pc468="" pripple="" role="option" id="pn_id_31_2" aria-setsize="5" aria-posinset="3" aria-selected="false" data-p-focused="false" data-p-selected="false" data-p-disabled="false" data-p-highlight="false" data-pc-section="option" class="p-ripple p-select-option" aria-label="Date created (oldest)">…</li> - attempting click action - waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting for element to be visible, enabled and stable - element was detached from the DOM, retrying at ../src/pages/SearchPage.ts:608 606 | 607 | await this.sortByButton.click(); > 608 | await this.sortByDateCreatedOldest.click(); | ^ 609 | dates = await this.getDates(`Date "${createdRegistered}"`); 610 | this.assertSorting(dates, 'ascending'); 611 | } at ProfilePage.checkSortingByCreatedDate (/home/runner/work/angular-osf-playwright/angular-osf-playwright/src/pages/SearchPage.ts:608:40) at /home/runner/work/angular-osf-playwright/angular-osf-playwright/tests/profile.spec.ts:880:5
dates = await this.getDates(`Date "${createdRegistered}"`);
this.assertSorting(dates, 'ascending');
}
Expand Down
30 changes: 30 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,33 @@ export async function clickExpectingPopup(page: Page, locator: Locator): Promise
await popup.waitForLoadState();
return popup;
}

/**
* Search-result card title links are matched by `.first()` on a generic
* class-based locator that gets re-evaluated fresh at click time. Card-validation
* flows read several fields off the "first" card, then click its title link many
* awaits later (accordion expand, `present()` checks, etc.) - if the live search
* results re-sort or refresh in between (verified as a real, if infrequent,
* occurrence against this suite's shared, non-mocked backend), `.first()` can
* silently resolve to a *different* resource by click time, opening the wrong
* popup and failing the comparison against the fields already read. Capture the
* anchor's `href` right after reading the title, then click by that href
* specifically so the same resource that was read is the one that gets clicked.
*
* `href` alone isn't a unique key, though: a card's own "URL:" secondary-metadata
* link (inside the accordion these flows expand before clicking the title) points
* at that same resource, so `a[href="..."]` matches both - verified live via
* `tests/_debug_inspect.spec.ts` per CLAUDE.md. Scope to the title link's own
* `data-test-search-result-card-title-link` marker as well so an href match can
* only ever resolve to the actual title anchor.
*/
export async function clickExpectingPopupByHref(
page: Page,
titleLocator: Locator,
href: string | null
): Promise<Page> {
const target = href
? page.locator(`a[data-test-search-result-card-title-link][href="${href}"]`).first()
: titleLocator;
return clickExpectingPopup(page, target);
}
Loading
Loading