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
1 change: 1 addition & 0 deletions .github/workflows/single_test_runner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ on:
- registrationSidebar.spec.ts
- search.spec.ts
- user.spec.ts
- projectFiles.spec.ts
browser:
description: 'Browser'
required: true
Expand Down
1 change: 1 addition & 0 deletions config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export const ZOTERO_USER = env('ZOTERO_USER');
export const ZOTERO_PASSWORD = env('ZOTERO_PASSWORD');
export const DATAVERSE_URL = env('DATAVERSE_URL');
export const DATAVERSE_API_TOKEN = env('DATAVERSE_API_TOKEN');
export const ADDONS_TEST_PROJECT_TITLE = 'OSF Test Project for Addons';

// Populated at runtime by the `waffledPages` fixture (see src/fixtures/index.ts),
// mirroring `settings.EMBER_PAGES` being set dynamically in the old conftest.py.
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"@types/node": "^22.7.0",
"@types/node": "^22.20.1",
"cross-env": "^7.0.3",
"typescript": "^5.6.0"
},
Expand Down
40 changes: 15 additions & 25 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,30 +47,20 @@ export default defineConfig({
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
launchOptions: {
firefoxUserPrefs: {
// Headed Firefox on Windows stops painting (and firing requestAnimationFrame)
// while its window is covered by other windows. Playwright's "stable" check
// waits on animation frames, so every click then hangs on "waiting for
// element to be visible, enabled and stable" until it times out.
'widget.windows.window_occlusion_tracking.enabled': false,
},
},
},
},
{
name: 'edge',
use: { ...devices['Desktop Edge'], channel: 'msedge' },
},
{
// Playwright's WebKit engine - the closest thing to Safari available
// cross-platform (real Safari automation only runs on macOS).
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// // Playwright's WebKit engine - the closest thing to Safari available
// // cross-platform (real Safari automation only runs on macOS).
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },

],
});
25 changes: 24 additions & 1 deletion src/api/osfApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,28 @@ export async function updateRegistrationTitle(
});
}

export async function getNodeIdByTitle(
session: OsfSession,
title: string
): Promise<string | undefined> {
try {
const url = `/v2/nodes/?filter[title]=${encodeURIComponent(title)}`;

const response = await session.get(url);

const data = response.data;

if (data && data.length > 0) {
return data[0].id;
}

return undefined;
} catch (error) {
console.error(`Failed to get node by title "${title}":`, error);
throw error;
}
}

/**
* Replaces a registration's subjects with the given top-level subject names (looked
* up in the OSF registries provider's taxonomy, which is what the metadata page's
Expand All @@ -930,4 +952,5 @@ export async function updateRegistrationSubjects(
await session.put(`/v2/registrations/${registrationGuid}/relationships/subjects/`, {
data: ids.map((id) => ({ type: 'subjects', id })),
});
}

}
52 changes: 45 additions & 7 deletions src/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,24 @@ export interface ProjectPageStub {
guid: string;
}

type Fixtures = {
type WorkerFixtures = {
session: OsfSession;
checkCredentials: void;
fake: Faker;
waffledPages: void;
};

type Fixtures = {
//session: OsfSession;
//checkCredentials: void;
fake: Faker;
//waffledPages: void;
hideFooterSlideIn: void;
hideMetadataFeaturePopover: void;
defaultLogout: void;
mustBeLoggedIn: void;
userLoggedIn: boolean;
logInIfNotAlready: void;
mustBeLoggedInAsUserOne: void;
mustBeLoggedInAsUserTwo: void;
mustBeLoggedInAsProfileUser: void;
loginAsUserWithRegistrations: void;
Expand All @@ -54,6 +61,7 @@ type Fixtures = {
defaultProjectWithTags: osfApi.OsfProject;
publicLinkProject: osfApi.OsfProject;
defaultProjectWithAllMetadata: osfApi.OsfProject;
defaultAddonsProject: osfApi.OsfProject;
};

function getSessionCookieName(): string {
Expand All @@ -63,12 +71,12 @@ function getSessionCookieName(): string {
return `osf_${match ? match[1] : settings.DOMAIN}`;
}

export const test = base.extend<Fixtures>({
session: async ({}, use) => {
export const test = base.extend<Fixtures, WorkerFixtures>({
session: [async ({}, use) => {
const session = await createSession();
await use(session);
await session.dispose();
},
}, { scope: 'worker' }],

// Port of `check_credentials` (autouse). `pytest.exit` aborted the whole session on
// failure; here we fail fast with a clear error on the current test instead.
Expand All @@ -81,9 +89,10 @@ export const test = base.extend<Fixtures>({
}
await use();
},
{ auto: true },
{ auto: true, scope: 'worker' },
],


fake: async ({}, use) => {
await use(faker);
},
Expand All @@ -93,7 +102,7 @@ export const test = base.extend<Fixtures>({
settings.runtime.emberPages = await osfApi.waffledPages(session);
await use();
},
{ auto: true },
{ auto: true, scope: 'worker' },
],

hideFooterSlideIn: async ({ page }, use) => {
Expand Down Expand Up @@ -140,6 +149,12 @@ export const test = base.extend<Fixtures>({
await use();
},

mustBeLoggedInAsUserOne: async ({ page }, use) => {
await safeLogin(page, settings.USER_ONE, settings.USER_ONE_PASSWORD);
await acceptCookies(page);
await use();
},

mustBeLoggedInAsUserTwo: async ({ page }, use) => {
await safeLogin(page, settings.USER_TWO, settings.USER_TWO_PASSWORD);
await acceptCookies(page);
Expand Down Expand Up @@ -367,6 +382,29 @@ export const test = base.extend<Fixtures>({
await use(project);
await project.delete();
},

defaultAddonsProject: async ({ session }, use) => {
/**
* Creates a new project through the api and returns it. Deletes the project at the end
* of the test run. If PREFERRED_NODE is set, returns the APIDetail of preferred node.
*/
let node: osfApi.OsfProject;

if (settings.PREFERRED_NODE) {
node = await osfApi.getNode(session);
} else {
const nodeId = await osfApi.getNodeIdByTitle(session, settings.ADDONS_TEST_PROJECT_TITLE);
if (!nodeId) {
throw new Error('Could not find node with title "OSF Test Project for Addons"');
}
node = await osfApi.getNode(session, nodeId);
}

await use(node);
// teardown — the Python docstring says "Deletes the project at the end of the test run,"
// but the code shown doesn't actually perform a delete. Add it here if that's expected:
// await osfApi.deleteNode(session, node.id);
},
});

export { expect };
130 changes: 130 additions & 0 deletions src/pages/FilesPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { Page, Locator, expect } from '@playwright/test';
import { GuidBasePage } from './GuidBasePage';
import * as settings from '../../config/settings';
import { present, clickExpectingPopup, waitUntilPageReady, hereThenGone } from '../utils';

export class FilesPage extends GuidBasePage {
static baseUrl = '{guid}/files/{provider}';
addonProvider: string;
// Locators

readonly addFileFolderButton: Locator;
readonly fileSelectedText: Locator;
readonly fileListMoveButton: Locator;
readonly fileListCopyButton: Locator;
readonly fileListDeleteButton: Locator;
readonly leftnavOsfstorageLink: Locator;
readonly selectAddon: Locator;
readonly addonsList: Locator;
readonly fileInput: Locator;
readonly searchInput: Locator;
readonly searchResults: Locator;
readonly sortDropdown: Locator;
readonly selectListContainer: Locator;


constructor(
page: Page,
verify: boolean = false,
guid: string = '',
domain: string = settings.OSF_HOME,
addonProvider: string = 'osfstorage',
) {
super(page, verify, guid, domain);
this.addonProvider = addonProvider;

// Prefer resilient, role-based or test-id selectors over fragile long utility classes
this.addFileFolderButton = page.locator('button.p-button-success');
this.fileSelectedText = page.locator('span.mr-2');
this.fileListMoveButton = page.locator('button.p-button-outlined:not(.p-button-success):not(.p-button-danger)');
this.fileListCopyButton = page.locator('button.p-button-success');
this.fileListDeleteButton = page.locator('button.p-button-danger');

this.leftnavOsfstorageLink = page.locator('[data-test-files-provider-link="osfstorage"]');
this.selectAddon = page.getByRole('button', { name: 'dropdown trigger' });
this.addonsList = page.locator('li.p-select-option');
this.fileInput = page.locator('input[type="file"]');
this.searchInput = page.getByPlaceholder('Search your files');
this.searchResults = page.locator('div.table-cell.flex.align-items-center');
this.sortDropdown = page.locator('div.p-select-dropdown').nth(1);
this.selectListContainer = page.locator('div.p-select-list-container');
}


get url(): string {
return `${this.domain}/${FilesPage.baseUrl
.replace('{guid}', this.guid)
.replace('{provider}', this.addonProvider)}`;
}

get identity(): Locator {
return this.page.locator('[data-test-file-search]');
}

async reload() {
await this.page.reload();
}

get downloadButton(): Locator {
return this.page.locator('button[aria-label="Download"]');
}

async selectFromAddonList(selection: string): Promise<void> {
await this.selectAddon.nth(0).click();
// Provider keys (e.g. 'googledrive') may differ from the UI label ('Google Drive') only by
// whitespace, so allow optional whitespace between every character.
const pattern = Array.from(selection.replace(/\s+/g, ''))
.map((ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('\\s*');
const targetAddon = this.addonsList.filter({
hasText: new RegExp(`^\\s*${pattern}\\s*$`, 'i'),
});
await targetAddon.first().click();
}

async selectSortFromList(sortName: string): Promise<void> {
// Select the second dropdown directly
await this.selectAddon.nth(1).click();

const sortOption = this.page
.locator('div.p-select-list-container li')
.filter({ hasText: new RegExp(`^\\s*${sortName}\\s*$`) });

await sortOption.click();
}

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

async clickOnFolderLink(folderName: string, parentRow?: Locator): Promise<void> {
const scope = parentRow || this.page;
await scope.locator('span', { hasText: folderName }).click();
}

async selectFromSearchResults(fileName: string): Promise<Locator | null> {
// Wait for the filtered files API response to complete before trusting the DOM
await this.page.waitForResponse(
(response) => response.url().includes(`filter%5Bname%5D=${encodeURIComponent(fileName)}`) && response.status() === 200,
{ timeout: 10000 }
).catch(() => undefined); // don't hard-fail if the URL pattern doesn't match exactly — fall through to DOM wait below

const matchingResult = this.page.locator('div.files-table-row').filter({
has: this.page.locator(`text="${fileName}"`), // exact text match, not substring
}).first();

try {
await matchingResult.waitFor({ state: 'visible', timeout: 10000 });
return matchingResult;
} catch {
return null;
}
}

async retrieveSearchResults(targetString: string): Promise<Locator[]> {
const matchingResults = this.searchResults.filter({ hasText: targetString });
return matchingResults.all();
}


}
Loading
Loading