diff --git a/.github/workflows/new_weekly_regression_tests.yml b/.github/workflows/new_weekly_regression_tests.yml
index 32d2b7a..d491166 100644
--- a/.github/workflows/new_weekly_regression_tests.yml
+++ b/.github/workflows/new_weekly_regression_tests.yml
@@ -34,6 +34,7 @@ on:
- Login
- Navbar
- Profile
+ - RegistrationSidebar
- Search
- User
browser:
@@ -171,6 +172,7 @@ jobs:
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" ;;
+ RegistrationSidebar) echo "path=tests/registrationSidebar.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" ;;
diff --git a/.github/workflows/single_test_runner.yml b/.github/workflows/single_test_runner.yml
index 6462351..1637d72 100644
--- a/.github/workflows/single_test_runner.yml
+++ b/.github/workflows/single_test_runner.yml
@@ -27,6 +27,7 @@ on:
- login.spec.ts
- navbar.spec.ts
- profile.spec.ts
+ - registrationSidebar.spec.ts
- search.spec.ts
- user.spec.ts
browser:
diff --git a/README.md b/README.md
index 2f9fe6e..ebdd7cc 100644
--- a/README.md
+++ b/README.md
@@ -103,6 +103,7 @@ tests/
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
+ registrationSidebar.spec.ts # port of tests/test_registration_sidebar.py
```
## Migration status
@@ -120,7 +121,7 @@ tick it in **both** files.
| 5 | Navbar | `tests/test_navbar.py` | `tests/navbar.spec.ts` | [x] Migrated |
| 6 | Dashboard | `tests/test_dashboard.py` | — | [ ] Not migrated |
| 7 | Collections | `tests/test_collections.py` | — | [ ] Not migrated |
-| 8 | Registration sidebar | `tests/test_registration_sidebar.py` | — | [ ] Not migrated |
+| 8 | Registration sidebar | `tests/test_registration_sidebar.py` | `tests/registrationSidebar.spec.ts` | [x] Migrated |
| 9 | Preprints | `tests/test_preprints.py` | — | [ ] Not migrated |
| 10 | Metadata | `tests/test_metadata.py` | — | [ ] Not migrated |
| 11 | Institutions | `tests/test_institutions.py` | — | [ ] Not migrated |
@@ -131,7 +132,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: **5 / 17** sections migrated.
+Progress: **6 / 17** sections migrated.
## Notable differences from the Python suite
@@ -185,5 +186,27 @@ Progress: **5 / 17** sections migrated.
`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.
+- **Registration sidebar**: `RegistrationPage.ts` (already used by `search.spec.ts` as
+ a scoped port of the overview page) is extended in place with the rest of
+ `pages/registries.py`/`components/registration.py` this section needs, rather than
+ duplicated into a new type - `search.spec.ts`'s existing fields are untouched.
+ Metadata/Files/Components/Links/Analytics page objects stay identity-only, since
+ `test_registration_sidebar.py` only ever navigates to them and checks `identity` -
+ their fuller Python page objects (metadata editing, file browsing, etc.) are out of
+ scope here. Two Python dead-code bugs were fixed rather than carried over (matching
+ the Section 3 precedent above): `get_authors_list`/`get_affiliations_list` `return`
+ from inside their loop in Python, so they only ever yielded the first item -
+ `getAuthorsList`/`getAffiliationsList` here return the full list; and
+ `RegistrationContributorsPage`'s Python `identity` locator passes an XPath string to
+ a CSS `By.CSS_SELECTOR` (never actually matches) - ported as `osf-contributors`
+ instead. `test_delete_resource`'s Python assertion also checked for a *lowercased*
+ `
` that could never match real markup (an always-true assertion) - ported as a
+ real check against the actual (capitalized) resource-type heading. Several
+ since-fixed timing races surfaced only under real load and are documented inline
+ where fixed: the contributors table, VOL table, and overview license/authors sections
+ all render asynchronously after their page's `identity` mounts; the contributors
+ search box filters with a debounce (up to ~2s) rather than instantly; and VOL/resource
+ delete confirmations are PrimeNG's `p-confirmdialog` (`.p-confirmdialog-accept-button`),
+ not the `p-dialog` the Add Contributor/Create VOL modals use.
## Next steps
diff --git a/package.json b/package.json
index 9ae4482..a17dd45 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
"test:all-browsers": "playwright test --project=chromium --project=firefox --project=edge --project=webkit",
"test:login": "playwright test tests/login.spec.ts",
"test:user": "playwright test tests/user.spec.ts",
+ "test:registration-sidebar": "playwright test tests/registrationSidebar.spec.ts",
"test:test": "cross-env TEST_ENV=test playwright test",
"test:test2": "cross-env TEST_ENV=test2 playwright test",
"test:test3": "cross-env TEST_ENV=test3 playwright test",
diff --git a/src/api/osfApi.ts b/src/api/osfApi.ts
index c9b9afd..ba69af8 100644
--- a/src/api/osfApi.ts
+++ b/src/api/osfApi.ts
@@ -1,5 +1,5 @@
import * as settings from '../../config/settings';
-import { createAddonSession, OsfApiError, OsfSession } from './session';
+import { createAddonSession, createSession, OsfApiError, OsfSession } from './session';
/**
* Port of the subset of `api/osf_api.py` that `tests/conftest.py`'s fixtures, plus
@@ -669,3 +669,182 @@ export async function getUserDetails(session: OsfSession, userName: string): Pro
const userGuid = await getUserGuid(session, userName);
return session.get(`/v2/users/${userGuid}/`);
}
+
+/**
+ * Below: functions ported for `tests/test_registration_sidebar.py`. Several of the
+ * Python originals open their own `client.Session(auth=(REGISTRATIONS_USER, ...))`
+ * rather than taking the caller's session - ported the same way via a dedicated
+ * `registrationsSession()` helper, since the fixture registrations these operate on
+ * are only writable by that user.
+ */
+
+let cachedRegistrationsSession: Promise | undefined;
+
+/** One shared REGISTRATIONS_USER-authenticated session, lazily created and reused. */
+function registrationsSession(): Promise {
+ if (!cachedRegistrationsSession) {
+ cachedRegistrationsSession = createSession(
+ settings.REGISTRATIONS_USER,
+ settings.REGISTRATIONS_USER_PASSWORD
+ );
+ }
+ return cachedRegistrationsSession;
+}
+
+/** Port of `get_most_recent_registration_node_id`. */
+export async function getMostRecentRegistrationNodeId(
+ session: OsfSession
+): Promise {
+ const data = await session.get('/v2/registrations/');
+ const match = (data.data as any[]).find(
+ (registration) =>
+ registration.attributes.public &&
+ registration.attributes.revision_state === 'approved' &&
+ !registration.attributes.withdrawn
+ );
+ return match?.id ?? null;
+}
+
+/** Port of `get_registration_by_title`. */
+export async function getRegistrationByTitle(title: string): Promise {
+ const session = await registrationsSession();
+ const data = await session.get('/v2/registrations/', { 'filter[title]': title });
+ return data.data?.[0]?.id ?? null;
+}
+
+/** Port of `get_registration_resource_id` - the most recent resource added today. */
+export async function getRegistrationResourceId(
+ registrationId: string
+): Promise {
+ const session = await registrationsSession();
+ const data = await session.get(`/v2/registrations/${registrationId}/resources/`);
+ const today = new Date().toISOString().slice(0, 10);
+ const match = (data.data as any[])?.find((resource: any) =>
+ resource.attributes.date_created.includes(today)
+ );
+ return match?.id ?? null;
+}
+
+/** Port of `delete_registration_resource`. */
+export async function deleteRegistrationResource(registrationId: string): Promise {
+ const session = await registrationsSession();
+ const resourceId = await getRegistrationResourceId(registrationId);
+ await session.delete(`/v2/resources/${resourceId}`);
+}
+
+/** Port of `create_registration_resource`. */
+export async function createRegistrationResource(
+ registrationGuid: string,
+ resourceType: string
+): Promise {
+ const session = await registrationsSession();
+ const existingResourceId = await getRegistrationResourceId(registrationGuid);
+ if (existingResourceId) {
+ await deleteRegistrationResource(registrationGuid);
+ }
+
+ const data = await session.post('/v2/resources/', {
+ data: {
+ relationships: {
+ registration: { data: { type: 'registrations', id: registrationGuid } },
+ },
+ type: 'resources',
+ },
+ });
+ const resourceId = data.data.id;
+ const apiResourceType = resourceType === 'Analytic Code' ? 'analytic_code' : resourceType;
+
+ await session.patch(`v2/resources/${resourceId}/`, {
+ data: {
+ id: resourceId,
+ attributes: {
+ pid: 'https://doi.org/10.17605',
+ resource_type: apiResourceType,
+ finalized: true,
+ },
+ type: 'resources',
+ },
+ });
+}
+
+/** Port of `delete_registration_contributor`. */
+export async function deleteRegistrationContributor(
+ registrationGuid: string,
+ userName: string
+): Promise {
+ const session = await registrationsSession();
+ const data = await session.get(`/v2/registrations/${registrationGuid}/contributors/`);
+ const match = (data.data as any[]).find((contributor: any) =>
+ contributor.embeds.users.data.attributes.full_name.includes(userName)
+ );
+ if (match) {
+ await session.delete(`/v2/registrations/${registrationGuid}/contributors/${match.embeds.users.data.id}/`);
+ }
+}
+
+/** Port of `get_registration_details`. */
+export async function getRegistrationDetails(
+ session: OsfSession,
+ registrationGuid: string
+): Promise {
+ const data = await session.get(`/v2/registrations/${registrationGuid}/`);
+ return data?.data ?? null;
+}
+
+/** Port of `get_registration_license_name`. */
+export async function getRegistrationLicenseName(
+ session: OsfSession,
+ registrationGuid: string
+): Promise {
+ const data = await session.get(`/v2/registrations/${registrationGuid}/`);
+ const licenseId = data.data.relationships.license.data.id;
+ const licenseData = await session.get(`v2/licenses/${licenseId}/`);
+ return licenseData.data.attributes.name;
+}
+
+/** Port of `get_registration_institutions`. */
+export async function getRegistrationInstitutions(
+ session: OsfSession,
+ registrationGuid: string
+): Promise {
+ const data = await session.get(`/v2/registrations/${registrationGuid}/institutions/`);
+ return (data.data as any[]).map((institution: any) => institution.id);
+}
+
+/** Port of `get_registration_provider`. */
+export async function getRegistrationProvider(
+ session: OsfSession,
+ registrationGuid: string
+): Promise {
+ const data = await session.get(`/v2/registrations/${registrationGuid}/`);
+ const providerId = data.data.relationships.provider.data.id;
+ const providerData = await session.get(`/v2/providers/registrations/${providerId}/`);
+ return providerData.data.attributes.name;
+}
+
+/** Port of `get_registration_subjects`. */
+export async function getRegistrationSubjects(
+ session: OsfSession,
+ registrationGuid: string
+): Promise {
+ const data = await session.get(`/v2/registrations/${registrationGuid}/subjects/`);
+ return (data.data as any[]).map((subject: any) => subject.attributes.text);
+}
+
+/** Port of `get_registration_contributors`. */
+export async function getRegistrationContributors(
+ session: OsfSession,
+ registrationGuid: string
+): Promise {
+ const data = await session.get(`/v2/registrations/${registrationGuid}/contributors/`);
+ return (data.data as any[]).map(
+ (contributor: any) => contributor.embeds.users.data.attributes.full_name
+ );
+}
+
+/** Port of `get_registration_vol_key`. */
+export async function getRegistrationVolKey(registrationGuid: string): Promise {
+ const session = await registrationsSession();
+ const data = await session.get(`/v2/registrations/${registrationGuid}/view_only_links/`);
+ return data.data?.[0]?.id ?? null;
+}
diff --git a/src/fixtures/index.ts b/src/fixtures/index.ts
index 852bdaf..47966a4 100644
--- a/src/fixtures/index.ts
+++ b/src/fixtures/index.ts
@@ -27,6 +27,7 @@ type Fixtures = {
fake: Faker;
waffledPages: void;
hideFooterSlideIn: void;
+ hideMetadataFeaturePopover: void;
defaultLogout: void;
mustBeLoggedIn: void;
userLoggedIn: boolean;
@@ -100,6 +101,18 @@ export const test = base.extend({
await use();
},
+ /**
+ * Port of `BaseSubmittedRegistrationPage.__init__`'s cookie set - prevents the "New
+ * Feature" popover from appearing on submitted registration pages, where it can
+ * overlay and intercept clicks on other elements (e.g. the side-nav links).
+ */
+ hideMetadataFeaturePopover: async ({ page }, use) => {
+ await page
+ .context()
+ .addCookies([{ name: 'metadataFeaturePopover', value: '1', url: settings.OSF_HOME }]);
+ await use();
+ },
+
defaultLogout: [
async ({ page }, use) => {
await use();
diff --git a/src/pages/RegistrationPage.ts b/src/pages/RegistrationPage.ts
index 9b25f95..7e345f8 100644
--- a/src/pages/RegistrationPage.ts
+++ b/src/pages/RegistrationPage.ts
@@ -1,19 +1,41 @@
-import { Locator } from '@playwright/test';
+import { Page, Locator, expect } from '@playwright/test';
+import * as settings from '../../config/settings';
import { BasePage } from './BasePage';
+import { RegistrationSideNavbar } from './components/RegistrationSideNavbar';
+import {
+ AddContributorModal,
+ CreateVolModal,
+ DeleteVolModal,
+} from './components/RegistrationModals';
/**
- * Scoped port of `pages/registries.py`'s `RegistrationDetailPage` (the registration
- * overview page) - only the fields `tests/test_search.py` reads. `title` is inherited
- * in Python from `BaseSubmittedRegistrationPage`. The full Python page object covers
- * much more (metadata editing, resources, moderation decisions, etc.) and is out of
- * scope here.
+ * Port of `pages/registries.py` / `components/registration.py` - the pages
+ * `tests/test_registration_sidebar.py` exercises. `RegistrationPage` below is the
+ * Registration Overview page; the rest of the file is its side-nav siblings
+ * (Metadata/Files/Resources/Wiki/Components/Links/Analytics/Contributors).
+ *
+ * Only Overview, Resources, Wiki and Contributors carry real behavior here -
+ * Metadata/Files/Components/Links/Analytics are only ever navigated to and checked
+ * for `identity` by the side-nav smoke tests in `tests/test_registration_sidebar.py`,
+ * so those page objects stay identity-only (their fuller Python page objects cover
+ * metadata editing, file browsing, etc. - out of scope for this file).
*/
+
+/** Port of `BaseSubmittedRegistrationPage.url`. */
+export function registrationUrl(guid: string, section = ''): string {
+ return section ? `${settings.OSF_HOME}/${guid}/${section}` : `${settings.OSF_HOME}/${guid}/`;
+}
+
export class RegistrationPage extends BasePage {
get identity(): Locator {
return this.page.locator('[data-test-page-heading]');
}
+ get sideNavbar(): RegistrationSideNavbar {
+ return new RegistrationSideNavbar(this.page);
+ }
+
get title(): Locator {
return this.page.locator('h1[data-test-page-heading]');
}
@@ -41,4 +63,392 @@ export class RegistrationPage extends BasePage {
get allContributors(): Locator {
return this.page.locator('[data-test-contributor-name]');
}
+
+ get overviewDescription(): Locator {
+ return this.page.locator('[data-test-registry-overview-metadata-description]');
+ }
+
+ get overviewDateCreated(): Locator {
+ return this.page.locator('[data-test-registry-overview-metadata-date-created]');
+ }
+
+ /** Verified live: this section renders twice on the overview page - `.first()` matches Python's `find_element` (first match). */
+ get associatedProject(): Locator {
+ return this.page
+ .locator('[data-test-registry-overview-metadata-associated-project-link]')
+ .first();
+ }
+
+ get alertInfoMessage(): Locator {
+ return this.page.locator('div.flex.w-full.align-items-center.justify-content-between > p');
+ }
+
+ get volContributorsText(): Locator {
+ return this.page.locator('osf-contributors-list div p');
+ }
+
+ get openPracticeResourceData(): Locator {
+ return this.page.locator('#registration-resources');
+ }
+
+ get addResourceButton(): Locator {
+ return this.page.getByRole('button', { name: 'Add Resource', exact: true });
+ }
+
+ get doiInputField(): Locator {
+ return this.page.getByPlaceholder('https://doi.org/');
+ }
+
+ get resourceTypeDropdown(): Locator {
+ return this.page.getByText('Select A Resource Type', { exact: true });
+ }
+
+ async selectResourceType(resourceType: string): Promise {
+ await this.resourceTypeDropdown.click();
+ await this.page
+ .locator('li.p-select-option')
+ .filter({ hasText: new RegExp(`^\\s*${resourceType}\\s*$`) })
+ .click();
+ }
+
+ get previewButton(): Locator {
+ return this.page.getByRole('button', { name: 'Preview', exact: true });
+ }
+
+ get resourceTypeAddButton(): Locator {
+ return this.page.locator('p-button.btn-full-width > button.p-button-primary');
+ }
+
+ get resourceBlock(): Locator {
+ return this.page.locator('.resource-block');
+ }
+
+ get resourceCardDescription(): Locator {
+ return this.page.locator('.resource-block p.mt-1');
+ }
+
+ get resourceTypeEditButton(): Locator {
+ return this.page.getByRole('button', { name: 'Edit', exact: true });
+ }
+
+ get resourceTypeDeleteButton(): Locator {
+ return this.page.getByRole('button', { name: 'Delete', exact: true });
+ }
+
+ get resourceTypeDeleteConfirm(): Locator {
+ return this.page.getByRole('button', { name: 'Remove', exact: true });
+ }
+
+ get resourceDescription(): Locator {
+ return this.page.locator('#coi-reason');
+ }
+
+ get saveButton(): Locator {
+ return this.page.getByRole('button', { name: 'Save', exact: true });
+ }
+
+ async getSubjectsList(): Promise {
+ return this.page.locator('osf-subjects-list span.p-tag-label').allTextContents();
+ }
+
+ async getTagsList(): Promise {
+ return this.page.locator('osf-tags-list span.p-tag-label').allTextContents();
+ }
+
+ /**
+ * Port of `get_authors_list`/`get_affiliations_list`. The Python originals `return`
+ * from inside their `for` loop, so they only ever yield the first contributor /
+ * institution - dead-code bug (same pattern the README's Section 3 note already
+ * documents fixing elsewhere in this migration). Fixed here to return the full list.
+ */
+ async getAuthorsList(): Promise> {
+ const authors = this.page.locator('[data-test-contributor-name]');
+ // Same async-render race as RegistrationContributorsPage.getContributorsList -
+ // verified live that reading immediately after `verify()` can see zero authors.
+ await authors.first().waitFor({ state: 'visible', timeout: 5000 }).catch(() => undefined);
+ const count = await authors.count();
+ const result: Array<{ name: string; link: string | null }> = [];
+ for (let i = 0; i < count; i++) {
+ const author = authors.nth(i);
+ result.push({
+ name: (await author.innerText()).trim(),
+ link: await author.getAttribute('href'),
+ });
+ }
+ return result;
+ }
+
+ async getAffiliationsList(): Promise {
+ const links = this.page.locator(
+ 'h3:text-is("Affiliated Institutions") ~ osf-affiliated-institutions-view a'
+ );
+ const hrefs = await links.evaluateAll((elements) =>
+ elements.map((el) => (el as HTMLAnchorElement).href)
+ );
+ return hrefs.map((href) => href.replace(/\/+$/, '').split('/').pop() as string);
+ }
+}
+
+export class RegistrationMetadataPage extends BasePage {
+ get identity(): Locator {
+ return this.page.locator('osf-metadata');
+ }
+}
+
+export class RegistrationFilesListPage extends BasePage {
+ get identity(): Locator {
+ return this.page.locator('osf-files-container');
+ }
+}
+
+export class RegistrationResourcesPage extends BasePage {
+ get identity(): Locator {
+ return this.page.locator('osf-registry-resources');
+ }
+}
+
+export class RegistrationComponentsPage extends BasePage {
+ get identity(): Locator {
+ return this.page.locator('osf-registry-components');
+ }
+}
+
+export class RegistrationLinksPage extends BasePage {
+ get identity(): Locator {
+ return this.page.locator('osf-registry-links');
+ }
+}
+
+export class RegistrationAnalyticsPage extends BasePage {
+ get identity(): Locator {
+ return this.page.locator('osf-analytics');
+ }
+
+ get alertInfoMessage(): Locator {
+ return this.page.locator('div.flex.w-full.align-items-center.justify-content-between > p');
+ }
+}
+
+export class RegistrationWikiPage extends BasePage {
+ get identity(): Locator {
+ return this.page.locator('osf-registry-wiki');
+ }
+
+ get alertInfoMessage(): Locator {
+ return this.page.locator('div.flex.w-full.align-items-center.justify-content-between > p');
+ }
+
+ get wikiPreviewText(): Locator {
+ return this.page.locator('osf-markdown div p');
+ }
+
+ get wikiEmptyText(): Locator {
+ return this.page.locator('p.font-italic');
+ }
+
+ get wikiVersion(): Locator {
+ return this.page.locator('span.p-select-label').first();
+ }
+
+ get comparePreviewText(): Locator {
+ return this.page.locator('span.min-w-max.mr-2');
+ }
+
+ /** Verified live: `div.mt-3` also matches two structural/editor wrapper divs above this one - the actual compare content renders last. */
+ get compareText(): Locator {
+ return this.page.locator('div.mt-3').last();
+ }
+
+ async clickOnButton(buttonName: string): Promise {
+ await this.page.getByRole('button', { name: buttonName, exact: true }).click();
+ }
+
+ /** `versionLabel` is a substring, e.g. `(Current)` or `(1)`, matching Python's `select_version_from_dropdown`. */
+ async selectVersionFromDropdown(versionLabel: string): Promise {
+ await this.page.locator('div.p-select-dropdown').click();
+ await this.page.locator('li', { hasText: versionLabel }).first().click();
+ }
+}
+
+export class RegistrationContributorsPage extends BasePage {
+ get identity(): Locator {
+ return this.page.locator('osf-contributors');
+ }
+
+ get searchInput(): Locator {
+ return this.page.getByPlaceholder('Search Registration Contributors');
+ }
+
+ /**
+ * Fills the search box and waits for the table's debounced filter to actually
+ * narrow to that one contributor (verified live: filtering can take up to ~2s -
+ * reading row/permission/button state right after `.fill()` races it and hits
+ * several still-unfiltered rows, e.g. `userPermission`/`removeButton` matching all
+ * 4 rows instead of 1).
+ */
+ async searchFor(contributorName: string): Promise {
+ await this.searchInput.fill(contributorName);
+ await expect(this.tableRows).toHaveCount(1, { timeout: 10000 });
+ }
+
+ get tableRows(): Locator {
+ return this.page.locator('tbody.p-datatable-tbody tr');
+ }
+
+ get contributorName(): Locator {
+ return this.page.locator('td p a');
+ }
+
+ /**
+ * Scoped to `tableRows` so this can't match the "Filter by permission" / "Bibliography"
+ * filter dropdowns above the table, which share the same `span.p-select-label` class
+ * (verified live via `_debug_inspect.spec.ts` per CLAUDE.md - a page-wide locator
+ * resolves 3 elements even after `searchFor` narrows the table to one row).
+ */
+ get userPermission(): Locator {
+ return this.tableRows.locator('span.p-select-label');
+ }
+
+ get removeButton(): Locator {
+ return this.page.locator('button:has(span.fa-trash)');
+ }
+
+ get reorderButton(): Locator {
+ return this.page.locator('div.p-datatable-reorderable-row-handle').first();
+ }
+
+ get volSection(): Locator {
+ return this.page.getByRole('heading', { name: 'View-only links', exact: true });
+ }
+
+ /**
+ * First link-name cell in the VOL table - matches Python's `link_name` locator
+ * (which likewise reads whichever row is first). The fixture registration this runs
+ * against already carries leftover VOLs from prior suite runs (verified live), so
+ * this is only reliable immediately after creating a new link, same as the Python
+ * original assumed.
+ */
+ get linkName(): Locator {
+ return this.page.locator('osf-view-only-table table tbody tr td').first();
+ }
+
+ /**
+ * The VOL URL for a given link name. The app stores it in the copy-link input's
+ * `id` attribute rather than its `value` (verified live via `_debug_inspect.spec.ts`
+ * per CLAUDE.md - an unusual but confirmed real pattern, not a guess).
+ */
+ volLinkFor(linkName: string): Locator {
+ return this.page.locator('osf-view-only-table tr', { hasText: linkName }).locator('input');
+ }
+
+ /**
+ * Row-scoped Delete button (`aria-label="Delete"`) for a given view-only link name -
+ * a bare `osf-view-only-table ... button` locator is ambiguous, since each row also
+ * carries a `aria-label="Copy to clipboard button"` button (verified live via
+ * `tests/_debug_inspect.spec.ts` per CLAUDE.md).
+ */
+ volDeleteButtonFor(linkName: string): Locator {
+ return this.page
+ .locator('osf-view-only-table tr', { hasText: linkName })
+ .getByRole('button', { name: 'Delete', exact: true });
+ }
+
+ async clickOnButton(buttonName: string): Promise {
+ await this.page.getByRole('button', { name: buttonName, exact: true }).click();
+ }
+
+ /**
+ * Navigates to the contributors page and waits for the VOL list's initial GET to
+ * finish. Verified live via `tests/_debug_inspect.spec.ts`: if a VOL is created
+ * (POST) while that GET is still in flight, the app drops the new link from the
+ * table until the next reload, so callers that create a VOL must wait for this first.
+ */
+ async gotoAndWaitForVolList(guid: string): Promise {
+ await Promise.all([
+ this.page.waitForResponse(
+ (response) =>
+ /\/view_only_links\/(\?|$)/.test(response.url()) && response.request().method() === 'GET'
+ ),
+ this.page.goto(registrationUrl(guid, 'contributors')),
+ ]);
+ await this.verify();
+ }
+
+ /**
+ * The contributors table renders asynchronously after `osf-contributors` (this
+ * page's `identity`) mounts - reading rows right after `verify()` can race it and
+ * see zero rows (same pattern as `ConnectAddonModal.getRowCount` in
+ * `UserModals.ts`). Waiting for `tableRows` itself isn't enough - verified live that
+ * a `tr` can be present and visible before its `td p a` contributor link renders -
+ * so wait for the link specifically. Bounded and swallowed (not a hard prerequisite)
+ * because a permission/bibliography filter can legitimately narrow the table to zero
+ * rows - callers that expect a non-empty result assert on the returned list instead.
+ */
+ async getContributorsList(): Promise {
+ await this.tableRows
+ .locator('td p a')
+ .first()
+ .waitFor({ state: 'visible', timeout: 5000 })
+ .catch(() => undefined);
+ return this.tableRows.locator('td p a').allTextContents();
+ }
+
+ async getOrderOfContributor(contributorName: string): Promise {
+ const names = (await this.getContributorsList()).map((name) => name.trim());
+ return names.indexOf(contributorName);
+ }
+
+ /**
+ * Scoped to `tbody` so this can't accidentally hit the "Filter by permission" /
+ * "Bibliography" dropdowns living above the table (both share the same
+ * `div.p-select-dropdown` class) - call after `searchInput` has narrowed the table
+ * to the single row being edited.
+ */
+ async selectPermissionFromDropdownListbox(permission: string): Promise {
+ await this.tableRows.locator('div.p-select-dropdown').click();
+ await this.page.locator('ul li', { hasText: permission }).first().click();
+ }
+
+ get permissionFilterDropdown(): Locator {
+ return this.page.locator('div.p-select-dropdown').first();
+ }
+
+ async selectPermissionFilterFromDropdownList(permission: string): Promise {
+ await this.permissionFilterDropdown.click();
+ await this.page.locator('ul li', { hasText: permission }).first().click();
+ }
+
+ get bibliographyFilterDropdown(): Locator {
+ return this.page.locator('span[aria-label="Bibliography"] ~ div').first();
+ }
+
+ async selectBibliographyFilterFromDropdownList(bibliography: string): Promise {
+ await this.bibliographyFilterDropdown.click();
+ await this.page.locator('ul li', { hasText: bibliography }).first().click();
+ }
+
+ async clickOnBibliographicCheckbox(): Promise {
+ await this.tableRows.locator('input[aria-label="Bibliographic Contributor"]').first().click();
+ }
+
+ /** Despite the name (matches Python's `verify_link_present`), returns `true` once the link is gone. */
+ async verifyLinkPresent(linkName: string): Promise {
+ const cell = this.page.locator('osf-view-only-table td', { hasText: linkName });
+ // The row removal isn't instant after confirming delete - wait for it, then check.
+ await cell.first().waitFor({ state: 'detached', timeout: 10000 }).catch(() => undefined);
+ return (await cell.count()) === 0;
+ }
+
+ // Components
+ get addContributorModal(): AddContributorModal {
+ return new AddContributorModal(this.page);
+ }
+
+ get createVolModal(): CreateVolModal {
+ return new CreateVolModal(this.page);
+ }
+
+ get deleteVolModal(): DeleteVolModal {
+ return new DeleteVolModal(this.page);
+ }
}
diff --git a/src/pages/components/RegistrationModals.ts b/src/pages/components/RegistrationModals.ts
new file mode 100644
index 0000000..ba0c081
--- /dev/null
+++ b/src/pages/components/RegistrationModals.ts
@@ -0,0 +1,88 @@
+import { Page, Locator } from '@playwright/test';
+
+/**
+ * Port of the registration-contributors-page modals in `components/registration.py`
+ * (`AddContributorsModal`, `CreateVOLModal`, `DeleteVOLModal`). Each PrimeNG dialog is
+ * portaled to `document.body`, not nested under any page-section root, and the app
+ * also runs a Help Scout Beacon widget that carries its own `role="dialog"` - so every
+ * dialog here is scoped by `[aria-modal="true"]` as well, not just `role="dialog"`,
+ * to avoid ever matching the Beacon widget.
+ */
+function dialog(page: Page): Locator {
+ return page.locator('[role="dialog"][aria-modal="true"]');
+}
+
+export class AddContributorModal {
+ constructor(protected readonly page: Page) {}
+
+ get searchInput(): Locator {
+ return dialog(this.page).getByPlaceholder('Search by name or user information');
+ }
+
+ /**
+ * The checkbox's native `` sits visually on top of PrimeNG's decorative
+ * `.p-checkbox-box` div (verified live via `_debug_inspect.spec.ts` per CLAUDE.md:
+ * a plain click on `.p-checkbox-box` fails actionability - "input intercepts
+ * pointer events" - every retry for the full 25s timeout). `check({ force: true })`
+ * on the input itself is the reliable target.
+ */
+ async selectContributorCheckboxByName(contributorName: string): Promise {
+ const row = dialog(this.page)
+ .locator('div.border-divider')
+ .filter({ has: this.page.getByRole('link', { name: contributorName, exact: true }) });
+ await row.locator('input[type="checkbox"]').check({ force: true });
+ }
+
+ async clickOnButton(buttonName: string): Promise {
+ await dialog(this.page).getByRole('button', { name: buttonName, exact: true }).click();
+ }
+
+ clickOnNext(): Promise {
+ return this.clickOnButton('Next');
+ }
+}
+
+export class CreateVolModal {
+ constructor(protected readonly page: Page) {}
+
+ get linkNameInput(): Locator {
+ return dialog(this.page).getByPlaceholder('Type link name');
+ }
+
+ async selectAnonymousCheckbox(): Promise {
+ await dialog(this.page).locator('#anonymous').click();
+ }
+
+ async clickOnButton(buttonName: string): Promise {
+ await dialog(this.page).getByRole('button', { name: buttonName, exact: true }).click();
+ }
+}
+
+/**
+ * Unlike `AddContributorModal`/`CreateVolModal` (PrimeNG `p-dialog`, `role="dialog"`),
+ * the VOL delete confirmation is PrimeNG's built-in `p-confirmdialog`
+ * (`role="alertdialog"`) - per CLAUDE.md, its buttons are
+ * `.p-confirmdialog-accept-button` / `.p-confirmdialog-reject-button`, not
+ * role/text-matched. Verified live: with a `role="dialog"` scope, the row's own
+ * still-visible "Delete" button behind the overlay makes `getByRole('button', {name:
+ * 'Delete'})` ambiguous.
+ */
+export class DeleteVolModal {
+ constructor(protected readonly page: Page) {}
+
+ get deleteButton(): Locator {
+ return this.page.locator('button.p-confirmdialog-accept-button');
+ }
+
+ get cancelButton(): Locator {
+ return this.page.locator('button.p-confirmdialog-reject-button');
+ }
+
+ async clickOnButton(buttonName: string): Promise {
+ if (buttonName === 'Delete') {
+ await this.deleteButton.click();
+ } else {
+ await this.cancelButton.click();
+ }
+ }
+}
diff --git a/src/pages/components/RegistrationSideNavbar.ts b/src/pages/components/RegistrationSideNavbar.ts
new file mode 100644
index 0000000..2d96abe
--- /dev/null
+++ b/src/pages/components/RegistrationSideNavbar.ts
@@ -0,0 +1,50 @@
+import { Page, Locator } from '@playwright/test';
+
+/**
+ * Port of `components/registration.py`'s `SubmittedSideNavbar` - the side navigation
+ * for a submitted registration. Rendered inside the app's single persistent
+ * `osf-sidenav` (shared with the rest of the app, see `Navbar.ts`), scoped here so a
+ * link like "Links" or "Analytics" can't accidentally match something in the global
+ * nav below it. `comments_link` (`Comments`) was never rendered on the live app during
+ * migration (feature-flagged off) and isn't ported - it wasn't used in
+ * `test_registration_sidebar.py` either.
+ */
+export class RegistrationSideNavbar {
+ constructor(protected readonly page: Page) {}
+
+ private link(name: string): Locator {
+ return this.page.locator('osf-sidenav').getByRole('link', { name, exact: true });
+ }
+
+ get overviewLink(): Locator {
+ return this.link('Overview');
+ }
+
+ get metadataLink(): Locator {
+ return this.link('Metadata');
+ }
+
+ get filesLink(): Locator {
+ return this.link('Files');
+ }
+
+ get resourcesLink(): Locator {
+ return this.link('Resources');
+ }
+
+ get wikiLink(): Locator {
+ return this.link('Wiki');
+ }
+
+ get componentsLink(): Locator {
+ return this.link('Components');
+ }
+
+ get linksLink(): Locator {
+ return this.link('Links');
+ }
+
+ get analyticsLink(): Locator {
+ return this.link('Analytics');
+ }
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index d10afe7..7e87c09 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -85,6 +85,43 @@ export async function waitForOverlayToDisappear(page: Page, timeout = 10000): Pr
.catch(() => undefined);
}
+/**
+ * Port of `normalize_api_date` from utils.py - truncates an API ISO date string to
+ * minute precision, keeping the literal Y/M/D/H/M digits as given (Python's
+ * `strftime` on the parsed value never re-applies a timezone conversion).
+ */
+export function normalizeApiDate(apiDate: string): string {
+ const match = apiDate.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
+ if (!match) {
+ throw new Error(`Unrecognized API date format: ${apiDate}`);
+ }
+ const [, year, month, day, hour, minute] = match;
+ return `${year}-${month}-${day}T${hour}:${minute}`;
+}
+
+/**
+ * Port of `normalize_ui_date` from utils.py. The Python original assumed the UI always
+ * renders in a fixed `America/New_York` wall clock and converted from that - verified
+ * live (via `_debug_inspect.spec.ts` per CLAUDE.md) that the current Angular app
+ * instead just formats dates in the browser's own local timezone, which Playwright
+ * inherits from the runner's system timezone (`playwright.config.ts` sets no
+ * `timezoneId` override, so browser and Node process agree). That makes this much
+ * simpler than the Python version: `new Date(uiDate)` already parses the displayed
+ * string as local time, so its `getTime()` is the correct absolute instant with no
+ * further timezone conversion needed - just read it back out in UTC and truncate to
+ * minute precision, matching `normalizeApiDate`'s raw (UTC) API value.
+ */
+export function normalizeUiDate(uiDate: string): string {
+ const parsed = new Date(uiDate);
+ if (Number.isNaN(parsed.getTime())) {
+ throw new Error(`Unrecognized UI date format: ${uiDate}`);
+ }
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${parsed.getUTCFullYear()}-${pad(parsed.getUTCMonth() + 1)}-${pad(
+ parsed.getUTCDate()
+ )}T${pad(parsed.getUTCHours())}:${pad(parsed.getUTCMinutes())}`;
+}
+
/** Port of `page_refresh_with_clean_storages` from utils.py. */
export async function pageRefreshWithCleanStorages(page: Page): Promise {
await page.evaluate(() => {
diff --git a/tests/registrationSidebar.spec.ts b/tests/registrationSidebar.spec.ts
new file mode 100644
index 0000000..64da5b4
--- /dev/null
+++ b/tests/registrationSidebar.spec.ts
@@ -0,0 +1,554 @@
+import * as settings from '../config/settings';
+import { test as base, expect } from '../src/fixtures';
+import * as osfApi from '../src/api/osfApi';
+import { logout } from '../src/pages/LoginPage';
+import { normalizeApiDate, normalizeUiDate, present } from '../src/utils';
+import { Navbar } from '../src/pages/components/Navbar';
+import {
+ RegistrationPage,
+ RegistrationMetadataPage,
+ RegistrationFilesListPage,
+ RegistrationResourcesPage,
+ RegistrationWikiPage,
+ RegistrationComponentsPage,
+ RegistrationLinksPage,
+ RegistrationAnalyticsPage,
+ RegistrationContributorsPage,
+ registrationUrl,
+} from '../src/pages/RegistrationPage';
+
+
+const test = base.extend<{}>({});
+
+const resourceTypes = ['Data', 'Analytic Code', 'Materials', 'Papers', 'Supplements'];
+
+test.describe('Submitted Registration Side Navigation', { tag: ['@smoke', '@core'] }, () => {
+ test.beforeEach(async ({ page, session, hideMetadataFeaturePopover }) => {
+ void hideMetadataFeaturePopover;
+ const guid = await osfApi.getMostRecentRegistrationNodeId(session);
+ if (!guid) throw new Error('No recent approved public registration found.');
+ await page.goto(registrationUrl(guid));
+ await new RegistrationPage(page).verify();
+ });
+
+ test('metadata link', async ({ page }) => {
+ await new RegistrationPage(page).sideNavbar.metadataLink.click();
+ await RegistrationMetadataPage.expectOn(page);
+ });
+
+ test('files link', async ({ page }) => {
+ await new RegistrationPage(page).sideNavbar.filesLink.click();
+ await RegistrationFilesListPage.expectOn(page);
+ });
+
+ test('resources link', async ({ page }) => {
+ await new RegistrationPage(page).sideNavbar.resourcesLink.click();
+ await RegistrationResourcesPage.expectOn(page);
+ });
+
+ test('wiki link', async ({ page }) => {
+ await new RegistrationPage(page).sideNavbar.wikiLink.click();
+ await RegistrationWikiPage.expectOn(page);
+ });
+
+ test('components link', async ({ page }) => {
+ await new RegistrationPage(page).sideNavbar.componentsLink.click();
+ await RegistrationComponentsPage.expectOn(page);
+ });
+
+ test('links link', async ({ page }) => {
+ await new RegistrationPage(page).sideNavbar.linksLink.click();
+ await RegistrationLinksPage.expectOn(page);
+ });
+
+ test('analytics link', async ({ page }) => {
+ await new RegistrationPage(page).sideNavbar.analyticsLink.click();
+ await RegistrationAnalyticsPage.expectOn(page);
+ });
+
+ test('overview link', async ({ page, session, hideMetadataFeaturePopover }) => {
+ void hideMetadataFeaturePopover;
+ const guid = await osfApi.getMostRecentRegistrationNodeId(session);
+ if (!guid) throw new Error('No recent approved public registration found.');
+ await page.goto(registrationUrl(guid, 'metadata/osf'));
+ await new RegistrationMetadataPage(page).verify();
+ await new RegistrationPage(page).sideNavbar.overviewLink.click();
+ await RegistrationPage.expectOn(page);
+ });
+});
+
+test.describe('Registration Outputs', { tag: '@core' }, () => {
+ let guid: string;
+
+ test.beforeEach(async ({ mustBeLoggedInAsRegistrationUser, hideMetadataFeaturePopover }) => {
+ void mustBeLoggedInAsRegistrationUser;
+ void hideMetadataFeaturePopover;
+ test.skip(settings.PRODUCTION, 'Test should not run on production');
+ const found = await osfApi.getRegistrationByTitle('Selenium Registration For Outputs Testing');
+ if (!found) throw new Error('Fixture registration not found: Selenium Registration For Outputs Testing');
+ guid = found;
+ });
+
+ for (const resourceType of resourceTypes) {
+ test(`add new resource - ${resourceType}`, async ({ page }) => {
+ await page.goto(registrationUrl(guid));
+ const regPage = new RegistrationPage(page);
+ await regPage.openPracticeResourceData.click();
+ await expect(regPage.addResourceButton).toBeVisible();
+
+ const existingResourceId = await osfApi.getRegistrationResourceId(guid);
+ if (existingResourceId) {
+ await osfApi.deleteRegistrationResource(guid);
+ await page.reload();
+ await expect(regPage.resourceBlock).toHaveCount(0);
+ }
+
+ await regPage.addResourceButton.click();
+ await regPage.doiInputField.fill('https://doi.org/10.1126/science.aar3646');
+ await regPage.selectResourceType(resourceType);
+ await regPage.previewButton.click();
+ await regPage.resourceTypeAddButton.click();
+
+ await expect(page.getByRole('heading', { name: resourceType, exact: true })).toBeVisible({
+ timeout: 10000,
+ });
+ await osfApi.deleteRegistrationResource(guid);
+ });
+ }
+
+ for (const resourceType of resourceTypes) {
+ test(`edit resource - ${resourceType}`, async ({ page, fake }) => {
+ const resourceDescription = fake.lorem.sentence(1);
+ await osfApi.createRegistrationResource(guid, resourceType);
+ await page.goto(registrationUrl(guid));
+ const regPage = new RegistrationPage(page);
+ await regPage.openPracticeResourceData.click();
+ await regPage.resourceTypeEditButton.click();
+
+ await regPage.doiInputField.fill('https://doi.org/10.1126/science.aar3646');
+ await regPage.resourceDescription.fill(resourceDescription);
+ await regPage.saveButton.click();
+
+ await expect(regPage.resourceCardDescription).toHaveText(resourceDescription);
+ await osfApi.deleteRegistrationResource(guid);
+ });
+ }
+
+ for (const resourceType of resourceTypes) {
+ test(`delete resource - ${resourceType}`, async ({ page }) => {
+ await osfApi.createRegistrationResource(guid, resourceType);
+ await page.goto(registrationUrl(guid));
+ const regPage = new RegistrationPage(page);
+ await regPage.openPracticeResourceData.click();
+ await regPage.resourceTypeDeleteButton.click();
+ await regPage.resourceTypeDeleteConfirm.click();
+
+ await page.reload();
+ await expect(page.getByRole('heading', { name: resourceType, exact: true })).toHaveCount(0);
+ });
+ }
+});
+
+test.describe('Registration Overview', { tag: '@core' }, () => {
+ let guid: string;
+
+ test.beforeEach(async ({ mustBeLoggedInAsRegistrationUser, hideMetadataFeaturePopover }) => {
+ void mustBeLoggedInAsRegistrationUser;
+ void hideMetadataFeaturePopover;
+ test.skip(settings.PRODUCTION, 'Test should not run on production');
+ const found = await osfApi.getRegistrationByTitle('Selenium Registration to Test Overview Page');
+ if (!found) throw new Error('Fixture registration not found: Selenium Registration to Test Overview Page');
+ guid = found;
+ });
+
+ test('verify registration overview', async ({ page, session }) => {
+ const registrationData = await osfApi.getRegistrationDetails(session, guid);
+ const apiDescription = registrationData.attributes.description;
+ const apiTagsList: string[] = registrationData.attributes.tags;
+ const apiCreatedDate = registrationData.attributes.date_created;
+ const apiRegisteredDate = registrationData.attributes.date_registered;
+ const apiRegistrationType = registrationData.attributes.registration_supplement;
+ const apiRegistrationLicense = await osfApi.getRegistrationLicenseName(session, guid);
+ const apiInstitutionList = await osfApi.getRegistrationInstitutions(session, guid);
+ const apiProvider = await osfApi.getRegistrationProvider(session, guid);
+ const apiSubjects = await osfApi.getRegistrationSubjects(session, guid);
+ const apiAuthors = await osfApi.getRegistrationContributors(session, guid);
+ const apiAssociatedProjectGuid = registrationData.relationships.registered_from.data.id;
+ const apiRegistrationDoi = `10.70102/FK2OSF.IO/${guid.toUpperCase()}`;
+
+ await page.goto(registrationUrl(guid));
+ const regPage = new RegistrationPage(page);
+ await regPage.verify();
+
+ const uiDescription = (await regPage.overviewDescription.innerText()).trim();
+ const uiTagsList = await regPage.getTagsList();
+ const uiCreatedDate = (await regPage.overviewDateCreated.innerText()).trim();
+ const uiRegisteredDate = (await regPage.registeredDate.innerText()).trim();
+ const uiRegistrationType = (await regPage.overviewRegistrationType.innerText()).trim();
+ // The license name resolves via its own follow-up lookup after the rest of the
+ // overview loads (verified live: reading it right away raced an empty string).
+ await expect(regPage.overviewLicense).not.toBeEmpty();
+ const uiLicenseInfo = (await regPage.overviewLicense.innerText()).trim();
+ const uiSubjectsList = await regPage.getSubjectsList();
+ const uiAuthorsList = await regPage.getAuthorsList();
+ const uiAssociatedProject = (await regPage.associatedProject.innerText()).trim();
+ const uiRegistrationDoi = (await regPage.registrationDoi.innerText()).trim();
+
+ expect(uiDescription).toBe(apiDescription);
+ if (settings.DOMAIN !== 'stage3') {
+ expect(normalizeUiDate(uiCreatedDate)).toBe(normalizeApiDate(apiCreatedDate));
+ expect(normalizeUiDate(uiRegisteredDate)).toBe(normalizeApiDate(apiRegisteredDate));
+ }
+
+ if (settings.DOMAIN !== 'stage1') {
+ const uiRegistry = (await regPage.overviewRegistry.innerText()).trim();
+ expect(uiRegistry).toBe(apiProvider);
+ const uiAffiliatedInstitutionList = await regPage.getAffiliationsList();
+ expect([...uiAffiliatedInstitutionList].sort()).toEqual([...apiInstitutionList].sort());
+ }
+
+ expect([...uiTagsList].sort()).toEqual([...apiTagsList].sort());
+ expect(uiRegistrationType).toBe(apiRegistrationType);
+ expect(uiLicenseInfo).toBe(apiRegistrationLicense);
+ expect([...uiSubjectsList].sort()).toEqual([...apiSubjects].sort());
+ expect(uiAssociatedProject).toContain(apiAssociatedProjectGuid);
+ expect(uiRegistrationDoi).toBe(apiRegistrationDoi);
+
+ let lastName = '';
+ let lastProfileName = '';
+ for (const { name, link } of uiAuthorsList) {
+ if (!link) continue;
+ await page.goto(link);
+ lastProfileName = (await page.locator('h1').first().innerText()).trim();
+ expect(apiAuthors).toContain(name);
+ lastName = name;
+ }
+ expect(lastProfileName).toContain(lastName);
+ });
+});
+
+test.describe('Registration Contributors', { tag: '@core' }, () => {
+ let guid: string;
+
+ test.beforeEach(async ({ mustBeLoggedInAsRegistrationUser, hideMetadataFeaturePopover }) => {
+ void mustBeLoggedInAsRegistrationUser;
+ void hideMetadataFeaturePopover;
+ test.skip(settings.PRODUCTION, 'Test should not run on production');
+ const found = await osfApi.getRegistrationByTitle('Selenium Registration to Test Contributors Page');
+ if (!found) throw new Error('Fixture registration not found: Selenium Registration to Test Contributors Page');
+ guid = found;
+ });
+
+ test('add contributors', async ({ page }) => {
+ const newUser = settings.PRODUCTION ? 'OSF Tester1' : 'OSF Runscope Admin';
+
+ await osfApi.deleteRegistrationContributor(guid, newUser);
+ await page.goto(registrationUrl(guid, 'contributors'));
+ const contributorsPage = new RegistrationContributorsPage(page);
+ await contributorsPage.verify();
+
+ let contributorsList = await contributorsPage.getContributorsList();
+ expect(contributorsList).not.toContain(newUser);
+
+ await contributorsPage.clickOnButton('Add Contributor');
+ await contributorsPage.addContributorModal.searchInput.fill(newUser);
+ await contributorsPage.addContributorModal.selectContributorCheckboxByName(newUser);
+ await contributorsPage.addContributorModal.clickOnNext();
+ await contributorsPage.addContributorModal.clickOnButton('Done');
+
+ await page.reload();
+ await contributorsPage.verify();
+ contributorsList = await contributorsPage.getContributorsList();
+ expect(contributorsList).toContain(newUser);
+
+ await contributorsPage.searchFor(newUser);
+ await expect(contributorsPage.contributorName).toHaveText(newUser);
+ });
+
+ test('edit contributor permission', async ({ page }) => {
+ const contributorName = 'OSF Runscope Admin';
+ const newPermission = 'Administrator';
+
+ await page.goto(registrationUrl(guid, 'contributors'));
+ const contributorsPage = new RegistrationContributorsPage(page);
+ await contributorsPage.verify();
+
+ const contributorsList = await contributorsPage.getContributorsList();
+ expect(contributorsList).toContain(contributorName);
+
+ await contributorsPage.searchFor(contributorName);
+ const originalPermission = (await contributorsPage.userPermission.innerText()).trim();
+ expect(originalPermission).not.toBe(newPermission);
+
+ await contributorsPage.selectPermissionFromDropdownListbox(newPermission);
+ await contributorsPage.clickOnButton('Save');
+ const permissionAfterChange = (await contributorsPage.userPermission.innerText()).trim();
+ expect(permissionAfterChange).toBe(newPermission);
+
+ await contributorsPage.selectPermissionFilterFromDropdownList(newPermission);
+ const adminFilteredList = await contributorsPage.getContributorsList();
+ expect(adminFilteredList).toContain(contributorName);
+
+ await contributorsPage.selectPermissionFilterFromDropdownList('Read');
+ const readFilteredList = await contributorsPage.getContributorsList();
+ expect(readFilteredList).not.toContain(contributorName);
+
+ await contributorsPage.selectPermissionFilterFromDropdownList('Read + Write');
+ const writeFilteredList = await contributorsPage.getContributorsList();
+ expect(writeFilteredList).not.toContain(contributorName);
+ });
+
+ test('edit bibliographic status', async ({ page }) => {
+ const contributorName = 'OSF Runscope Admin';
+
+ await page.goto(registrationUrl(guid, 'contributors'));
+ const contributorsPage = new RegistrationContributorsPage(page);
+ await contributorsPage.verify();
+
+ await contributorsPage.selectBibliographyFilterFromDropdownList('Bibliographic');
+ const contributorsList = await contributorsPage.getContributorsList();
+ expect(contributorsList).toContain(contributorName);
+
+ await page.reload();
+ await contributorsPage.verify();
+ await contributorsPage.searchFor(contributorName);
+ await contributorsPage.clickOnBibliographicCheckbox();
+ await contributorsPage.clickOnButton('Save');
+ await page.reload();
+ await contributorsPage.verify();
+
+ await contributorsPage.selectBibliographyFilterFromDropdownList('Bibliographic');
+ const bibliographicUsers = await contributorsPage.getContributorsList();
+ expect(bibliographicUsers).not.toContain(contributorName);
+
+ await page.reload();
+ await contributorsPage.verify();
+ await contributorsPage.selectBibliographyFilterFromDropdownList('Non-Bibliographic');
+ const nonBibliographicUsers = await contributorsPage.getContributorsList();
+ expect(nonBibliographicUsers).toContain(contributorName);
+ });
+
+ test('reorder contributors', async ({ page }) => {
+ await page.goto(registrationUrl(guid, 'contributors'));
+ const contributorsPage = new RegistrationContributorsPage(page);
+ await contributorsPage.verify();
+
+ const contributorsList = (await contributorsPage.getContributorsList()).map((name) => name.trim());
+ expect(contributorsList.length, 'reorder needs at least 3 contributors').toBeGreaterThan(2);
+ const sourceOrder = contributorsList.length - 1;
+ const contributorName = contributorsList[sourceOrder];
+ const targetOrder = 1;
+ const rows = contributorsPage.tableRows;
+ const sourceHandle = rows.nth(sourceOrder).locator('div.p-datatable-reorderable-row-handle');
+ const targetHandle = rows.nth(targetOrder).locator('div.p-datatable-reorderable-row-handle');
+ await sourceHandle.dragTo(targetHandle);
+
+ await contributorsPage.clickOnButton('Save');
+ await expect(page.getByRole('heading', { name: 'Contributors', exact: true })).toBeVisible();
+
+ await page.reload();
+ await contributorsPage.verify();
+ const newOrder = await contributorsPage.getOrderOfContributor(contributorName);
+ expect(newOrder).toBe(targetOrder);
+ });
+
+ test('remove contributors', async ({ page }) => {
+ const newUser = 'OSF Runscope Admin';
+
+ await page.goto(registrationUrl(guid, 'contributors'));
+ const contributorsPage = new RegistrationContributorsPage(page);
+ await contributorsPage.verify();
+
+ const contributorsList = await contributorsPage.getContributorsList();
+ expect(contributorsList).toContain(newUser);
+
+ await page.reload();
+ await contributorsPage.verify();
+ await contributorsPage.searchFor(newUser);
+ await contributorsPage.removeButton.click();
+ await contributorsPage.clickOnButton('Remove');
+
+ await page.reload();
+ await contributorsPage.verify();
+ const contributorsListAfter = await contributorsPage.getContributorsList();
+ expect(contributorsListAfter).not.toContain(newUser);
+ });
+});
+
+test.describe('Registration View-Only Links', { tag: '@core' }, () => {
+ let guid: string;
+
+ test.beforeEach(async ({ mustBeLoggedInAsRegistrationUser, hideMetadataFeaturePopover }) => {
+ void mustBeLoggedInAsRegistrationUser;
+ void hideMetadataFeaturePopover;
+ test.skip(settings.PRODUCTION, 'Test should not run on production');
+ const found = await osfApi.getRegistrationByTitle('Selenium Registration to Test View Only Links');
+ if (!found) throw new Error('Fixture registration not found: Selenium Registration to Test View Only Links');
+ guid = found;
+ });
+
+ test('anonymous registration view only link', async ({ page }) => {
+ const contributorsPage = new RegistrationContributorsPage(page);
+ await contributorsPage.gotoAndWaitForVolList(guid);
+
+ const volName = `Selenium_AVOL_${new Date().toISOString()}`;
+ await contributorsPage.volSection.scrollIntoViewIfNeeded();
+ await contributorsPage.clickOnButton('Create');
+ await contributorsPage.createVolModal.linkNameInput.fill(volName);
+ await contributorsPage.createVolModal.clickOnButton('Create');
+
+ await expect(contributorsPage.linkName).toHaveText(volName);
+ const volUrl = await contributorsPage.volLinkFor(volName).getAttribute('id');
+ if (!volUrl) throw new Error('VOL URL not found.');
+
+ await logout(page);
+ await page.waitForLoadState('networkidle').catch(() => undefined);
+ await page.goto(volUrl);
+ const volOverviewPage = new RegistrationPage(page);
+ await volOverviewPage.verify();
+
+ await expect(volOverviewPage.alertInfoMessage).toHaveText(
+ 'You are viewing OSF through a view-only link, which may limit the data you have permission to see.'
+ );
+ await expect(volOverviewPage.volContributorsText).toHaveText('Anonymous Contributors');
+ });
+
+ test('view files registration view only link', async ({ page, hideFooterSlideIn }) => {
+ void hideFooterSlideIn;
+ const volKey = await osfApi.getRegistrationVolKey(guid);
+ if (!volKey) throw new Error('No view-only link found for fixture registration.');
+
+ await logout(page);
+ await page.waitForLoadState('networkidle').catch(() => undefined);
+ await page.goto(`${settings.OSF_HOME}/${guid}/files?view_only=${volKey}`);
+ const filesPage = new RegistrationFilesListPage(page);
+ await filesPage.verify();
+
+ expect(await present(new Navbar(page).signInButton, settings.QUICK_TIMEOUT_MS)).toBe(true);
+ await expect(page.locator('div.flex.w-full.align-items-center.justify-content-between > p')).toHaveText(
+ 'You are viewing OSF through a view-only link, which may limit the data you have permission to see.'
+ );
+ });
+
+ test('view analytics registration view only link', async ({ page }) => {
+ const volKey = await osfApi.getRegistrationVolKey(guid);
+ if (!volKey) throw new Error('No view-only link found for fixture registration.');
+
+ await logout(page);
+ await page.waitForLoadState('networkidle').catch(() => undefined);
+ await page.goto(`${settings.OSF_HOME}/${guid}/analytics?view_only=${volKey}`);
+ const analyticsPage = new RegistrationAnalyticsPage(page);
+ await analyticsPage.verify();
+
+ expect(await present(new Navbar(page).signInButton, settings.QUICK_TIMEOUT_MS)).toBe(true);
+ await expect(analyticsPage.alertInfoMessage).toHaveText(
+ 'You are viewing OSF through a view-only link, which may limit the data you have permission to see.'
+ );
+ });
+
+ test('view wiki registration view only link', async () => {
+ test.skip(true, 'Wiki page not available');
+ });
+
+ test('delete anonymous registration view only link', async ({ page }) => {
+ await page.goto(registrationUrl(guid, 'contributors'));
+ const contributorsPage = new RegistrationContributorsPage(page);
+ await contributorsPage.verify();
+
+ await contributorsPage.volSection.scrollIntoViewIfNeeded();
+ await expect(contributorsPage.linkName).not.toBeEmpty();
+ const volName = (await contributorsPage.linkName.innerText()).trim();
+ await contributorsPage.volDeleteButtonFor(volName).click();
+ await contributorsPage.deleteVolModal.clickOnButton('Delete');
+
+ expect(await contributorsPage.verifyLinkPresent(volName)).toBe(true);
+ });
+
+ test('non anonymous registration view only link', async ({ page }) => {
+ const contributorsPage = new RegistrationContributorsPage(page);
+ await contributorsPage.gotoAndWaitForVolList(guid);
+
+ const volName = `Selenium_VOL_${new Date().toISOString()}`;
+ await contributorsPage.volSection.scrollIntoViewIfNeeded();
+ await contributorsPage.clickOnButton('Create');
+ await contributorsPage.createVolModal.linkNameInput.fill(volName);
+ await contributorsPage.createVolModal.selectAnonymousCheckbox();
+ await contributorsPage.createVolModal.clickOnButton('Create');
+
+ await expect(contributorsPage.linkName).toHaveText(volName);
+ const volUrl = await contributorsPage.volLinkFor(volName).getAttribute('id');
+ if (!volUrl) throw new Error('VOL URL not found.');
+
+ await logout(page);
+ await page.waitForLoadState('networkidle').catch(() => undefined);
+ await page.goto(volUrl);
+ const volOverviewPage = new RegistrationPage(page);
+ await volOverviewPage.verify();
+
+ await expect(volOverviewPage.alertInfoMessage).toHaveText(
+ 'You are viewing OSF through a view-only link, which may limit the data you have permission to see.'
+ );
+ const contributorsList = await volOverviewPage.getAuthorsList();
+ expect(contributorsList.length).toBeGreaterThan(0);
+ });
+
+ test('delete non anonymous registration view only link', async ({ page }) => {
+ await page.goto(registrationUrl(guid, 'contributors'));
+ const contributorsPage = new RegistrationContributorsPage(page);
+ await contributorsPage.verify();
+
+ await contributorsPage.volSection.scrollIntoViewIfNeeded();
+
+ await expect(contributorsPage.linkName).not.toBeEmpty();
+ const volName = (await contributorsPage.linkName.innerText()).trim();
+ await contributorsPage.volDeleteButtonFor(volName).click();
+ await contributorsPage.deleteVolModal.clickOnButton('Delete');
+
+ expect(await contributorsPage.verifyLinkPresent(volName)).toBe(true);
+ });
+});
+
+test.describe('Registration Wiki', { tag: '@core' }, () => {
+ let guid: string;
+
+ test.beforeEach(async ({ mustBeLoggedInAsRegistrationUser, hideMetadataFeaturePopover }) => {
+ void mustBeLoggedInAsRegistrationUser;
+ void hideMetadataFeaturePopover;
+ test.skip(settings.PRODUCTION, 'Test should not run on production');
+ const found = await osfApi.getRegistrationByTitle('Selenium Registration to Test Wiki Page');
+ if (!found) throw new Error('Fixture registration not found: Selenium Registration to Test Wiki Page');
+ guid = found;
+ });
+
+ test('view wiki versions', async ({ page }) => {
+ const originalText = 'Selenium Testing - Wiki Home Page';
+ await page.goto(registrationUrl(guid, 'wiki'));
+ const wikiPage = new RegistrationWikiPage(page);
+ await wikiPage.verify();
+
+ await expect(wikiPage.wikiVersion).toContainText('(Current)');
+ await expect(wikiPage.wikiPreviewText).toHaveText(originalText);
+
+ await wikiPage.selectVersionFromDropdown('(1)');
+ await expect(wikiPage.wikiEmptyText).toHaveText(
+ 'Add important information, links, or images here to describe your project.'
+ );
+ });
+
+ test('compare wiki versions', async ({ page }) => {
+ const wikiText = 'Selenium Testing - Wiki Home Page';
+ await page.goto(registrationUrl(guid, 'wiki'));
+ const wikiPage = new RegistrationWikiPage(page);
+ await wikiPage.verify();
+
+ await wikiPage.clickOnButton('View');
+ await wikiPage.clickOnButton('Compare');
+ await expect(page.getByRole('heading', { name: 'Compare', exact: true })).toBeVisible({
+ timeout: 15000,
+ });
+
+ await expect(wikiPage.comparePreviewText).toHaveText('Live preview to:');
+ await expect(wikiPage.wikiPreviewText).toHaveText(wikiText);
+ await expect(wikiPage.compareText).toHaveText(wikiText);
+ });
+});