From 9e2a0735739d758464a14421e20d2a7ede35b7a0 Mon Sep 17 00:00:00 2001 From: Ramya Virajamangala Date: Fri, 18 Sep 2026 08:03:06 -0400 Subject: [PATCH] Migrated download files tests --- config/settings.ts | 1 + package-lock.json | 2 +- package.json | 2 +- playwright.config.ts | 16 ++--- src/api/osfApi.ts | 22 ++++++ src/fixtures/index.ts | 52 ++++++++++++-- src/pages/FilesPage.ts | 120 ++++++++++++++++++++++++++++++++ src/pages/GuidBasePage.ts | 42 ++++++++++++ tests/project_files.spec.ts | 132 ++++++++++++++++++++++++++++++++++++ tsconfig.json | 3 +- 10 files changed, 374 insertions(+), 18 deletions(-) create mode 100644 src/pages/FilesPage.ts create mode 100644 src/pages/GuidBasePage.ts create mode 100644 tests/project_files.spec.ts diff --git a/config/settings.ts b/config/settings.ts index 2a155ce..e986966 100644 --- a/config/settings.ts +++ b/config/settings.ts @@ -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. diff --git a/package-lock.json b/package-lock.json index 7f9e0c2..b915914 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,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" } diff --git a/package.json b/package.json index d73482e..a373c1a 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/playwright.config.ts b/playwright.config.ts index bf3fe56..fda846f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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' }, + // }, ], }); diff --git a/src/api/osfApi.ts b/src/api/osfApi.ts index 245f419..756b5e2 100644 --- a/src/api/osfApi.ts +++ b/src/api/osfApi.ts @@ -636,3 +636,25 @@ export async function updateUserEducation( }, }); } + +export async function getNodeIdByTitle( + session: OsfSession, + title: string +): Promise { + 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; + } +} diff --git a/src/fixtures/index.ts b/src/fixtures/index.ts index 852bdaf..bd629a5 100644 --- a/src/fixtures/index.ts +++ b/src/fixtures/index.ts @@ -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; @@ -53,6 +60,7 @@ type Fixtures = { defaultProjectWithTags: osfApi.OsfProject; publicLinkProject: osfApi.OsfProject; defaultProjectWithAllMetadata: osfApi.OsfProject; + defaultAddonsProject: osfApi.OsfProject; }; function getSessionCookieName(): string { @@ -62,12 +70,12 @@ function getSessionCookieName(): string { return `osf_${match ? match[1] : settings.DOMAIN}`; } -export const test = base.extend({ - session: async ({}, use) => { +export const test = base.extend({ + 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. @@ -80,9 +88,10 @@ export const test = base.extend({ } await use(); }, - { auto: true }, + { auto: true, scope: 'worker' }, ], + fake: async ({}, use) => { await use(faker); }, @@ -92,7 +101,7 @@ export const test = base.extend({ settings.runtime.emberPages = await osfApi.waffledPages(session); await use(); }, - { auto: true }, + { auto: true, scope: 'worker' }, ], hideFooterSlideIn: async ({ page }, use) => { @@ -127,6 +136,12 @@ export const test = base.extend({ 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); @@ -354,6 +369,29 @@ export const test = base.extend({ 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 }; diff --git a/src/pages/FilesPage.ts b/src/pages/FilesPage.ts new file mode 100644 index 0000000..4a279ae --- /dev/null +++ b/src/pages/FilesPage.ts @@ -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 { + // 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 { + // 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 { + await this.page.getByRole('button', { name: buttonName }).click(); + } + + async clickOnFolderLink(folderName: string, parentRow?: Locator): Promise { + const scope = parentRow || this.page; + await scope.locator('span', { hasText: folderName }).click(); + } + + async selectFromSearchResults(fileName: string): Promise { + // 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 { + const matchingResults = this.searchResults.filter({ hasText: targetString }); + return matchingResults.all(); + } + +} diff --git a/src/pages/GuidBasePage.ts b/src/pages/GuidBasePage.ts new file mode 100644 index 0000000..b1d5658 --- /dev/null +++ b/src/pages/GuidBasePage.ts @@ -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 { + await this.page.goto(this.url); + try { + await this.page.getByText('Accept cookies').click({ timeout: 3000 }); + } catch { + // banner not present, continue + } + return this; + } +} \ No newline at end of file diff --git a/tests/project_files.spec.ts b/tests/project_files.spec.ts new file mode 100644 index 0000000..e05963c --- /dev/null +++ b/tests/project_files.spec.ts @@ -0,0 +1,132 @@ +import { test as base, expect } from '../src/fixtures'; +import { Page, Locator } from '@playwright/test'; +import { FilesPage } from '../src/pages/FilesPage'; +import * as osfApi from '../src/api/osfApi'; +import * as settings from '../config/settings'; +import { present, clickExpectingPopup } from '../src/utils'; +import fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const test = base.extend<{ + filesPage: FilesPage; + defaultAddonsProject: osfApi.OsfProject; +}>({ + filesPage: async ({ page, defaultAddonsProject, mustBeLoggedIn }, use) => { + void mustBeLoggedIn; // ensure login fixture resolves before this runs; + + const filesPage = new FilesPage( + page, + false, + defaultAddonsProject.id, + settings.OSF_HOME, + 'osfstorage' // or whatever default/appropriate addon provider + ); + await filesPage.goto(); + await use(filesPage); + }, + +}); + +async function findFileBySearch(filesPage: FilesPage, targetFileName: string) { + // Search for target file + await filesPage.searchInput.clear(); + await filesPage.searchInput.fill(targetFileName); + const row = await filesPage.selectFromSearchResults(targetFileName); + return row; +} + +async function findFolderBySearch(filesPage: FilesPage, targetFolderName: string) { + // Search for target file + await filesPage.searchInput.clear(); + await filesPage.searchInput.fill(targetFolderName); + const row = await filesPage.selectFromSearchResults(targetFolderName); + return row; +} + +async function verifyFileDownload( + page: Page, + filesPage: FilesPage, + fileName: string, + provider: string +) { + /** + * Helper function to verify the file download functionality on the Project Files page. + */ + + // If running on local machine, first check if the download file already exists + // in the Downloads folder. If so then delete the old copy before attempting to + // download a new one. + const filePath = path.join(os.homedir(), 'Downloads', fileName); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + + const rowPromise = findFileBySearch(filesPage, fileName); + const row = await rowPromise; + if (!row) { + throw new Error(`Could not find row for file: ${fileName}`); + } + + // Click the File Action menu button at the far right side of the row to show the + // menu options. Then click the Download option from this menu. + const menuButton = row.locator( + 'button.p-ripple.p-button.p-button-contrast.p-button-icon-only.p-button-raised.p-button-sm.p-button-text.p-component' + ); + await menuButton.click(); + + + // The menu overlay renders outside the row (PrimeNG overlay), so scope to page, not row + const downloadButton = page.locator('li#download'); // overlay renders via appendto="body", outside row + const downloadPromise = page.waitForEvent('download'); + + await downloadButton.click(); + const download = await downloadPromise; + + // Save it explicitly to a known path (or just verify via the Download object's own API) + const downloadPath = path.join(os.homedir(), 'Downloads', fileName); + await download.saveAs(downloadPath); + + await filesPage.reload(); + + if (provider !== 'osfstorage') { + //await filesPage.selectAddon.click(); + await filesPage.selectFromAddonList(provider); + } + + const currentDate = new Date(); + expect(fs.existsSync(filePath)).toBeTruthy(); + + const stats = fs.statSync(filePath); + const fileModDate = new Date(stats.mtime); + expect(fileModDate.toDateString()).toBe(currentDate.toDateString()); +} + +// Example parameterized test replacing python's provider fixture +const providers = ['osfstorage', 's3', 'box', 'bitbucket', 'dataverse', 'dropbox', 'figshare', 'github', 'gitlab', 'googledrive', 'onedrive', 'owncloud']; +//const providers = ['s3']; + +test.describe('Project Files Page', { tag: '@core' }, () => { + test.beforeEach(() => { + test.skip(settings.PRODUCTION, 'Test should not run on production'); + }); + + for (const provider of providers) { + test(`Download a single file from ${provider}`, async ({ page, filesPage }) => { + // Substitute with your actual project ID or fixture login + const currentBrowser = page.context().browser()?.browserType().name(); + const currentBrowserName: string = currentBrowser === 'chromium' ? 'chrome' : (currentBrowser ?? 'unknown'); + const fileName = 'download_' + currentBrowserName + '_' + provider + '.txt'; + + if (provider !== 'osfstorage') { + //await filesPage.selectAddon.click(); + await filesPage.selectFromAddonList(provider); + } + + // Trigger and verify download + + await verifyFileDownload(page, filesPage, fileName, provider) + + }); + } +}) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index ac6c43b..420e30a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,8 @@ "compilerOptions": { "target": "ES2022", "module": "commonjs", - "moduleResolution": "node", + "moduleResolution": "node10", + "ignoreDeprecations": "6.0", "lib": ["ES2022", "DOM"], "strict": true, "esModuleInterop": true,