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 config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,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 @@ -24,7 +24,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
16 changes: 8 additions & 8 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,13 @@ export default defineConfig({
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'edge',
use: { ...devices['Desktop Edge'], channel: 'msedge' },
},
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
],
});
22 changes: 22 additions & 0 deletions src/api/osfApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,3 +636,25 @@ export async function updateUserEducation(
},
});
}

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;
}
}
52 changes: 45 additions & 7 deletions src/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,23 @@ 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;
defaultLogout: void;
mustBeLoggedIn: void;
userLoggedIn: boolean;
logInIfNotAlready: void;
mustBeLoggedInAsUserOne: void;
mustBeLoggedInAsUserTwo: void;
mustBeLoggedInAsProfileUser: void;
loginAsUserWithRegistrations: void;
Expand All @@ -53,6 +60,7 @@ type Fixtures = {
defaultProjectWithTags: osfApi.OsfProject;
publicLinkProject: osfApi.OsfProject;
defaultProjectWithAllMetadata: osfApi.OsfProject;
defaultAddonsProject: osfApi.OsfProject;
};

function getSessionCookieName(): string {
Expand All @@ -62,12 +70,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 @@ -80,9 +88,10 @@ export const test = base.extend<Fixtures>({
}
await use();
},
{ auto: true },
{ auto: true, scope: 'worker' },
],


fake: async ({}, use) => {
await use(faker);
},
Expand All @@ -92,7 +101,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 @@ -127,6 +136,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 @@ -354,6 +369,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 };
120 changes: 120 additions & 0 deletions src/pages/FilesPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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;


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');
}

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> {
// Replaces explicit loops with direct text-based locators
await this.selectAddon.nth(0).click();
const targetAddon = this.addonsList.filter({
hasText: new RegExp(`^\\s*${selection}\\s*$`, 'i'),
});
await targetAddon.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();
}

}
42 changes: 42 additions & 0 deletions src/pages/GuidBasePage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// guid-base-page.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';
import * as settings from '../../config/settings';


export abstract class GuidBasePage extends BasePage {
static baseUrl: string = '';
guid: string;
domain: string;

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

get url(): string {
const baseUrl = (this.constructor as typeof GuidBasePage).baseUrl;
if (baseUrl.includes('{guid}')) {
return `${this.domain}/${baseUrl.replace('{guid}', this.guid)}`;
} else {
throw new Error('No {guid} placeholder in base_url specified.');
}
}

// guid-base-page.ts
async goto(): Promise<this> {
await this.page.goto(this.url);
try {
await this.page.getByText('Accept cookies').click({ timeout: 3000 });
} catch {
// banner not present, continue
}
return this;
}
}
Loading