diff --git a/.github/workflows/single_test_runner.yml b/.github/workflows/single_test_runner.yml index 1637d72..2d88897 100644 --- a/.github/workflows/single_test_runner.yml +++ b/.github/workflows/single_test_runner.yml @@ -30,6 +30,7 @@ on: - registrationSidebar.spec.ts - search.spec.ts - user.spec.ts + - projectFiles.spec.ts browser: description: 'Browser' required: true diff --git a/config/settings.ts b/config/settings.ts index b8c780d..c8aa2b2 100644 --- a/config/settings.ts +++ b/config/settings.ts @@ -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. 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 a17dd45..025814a 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,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 0644c99..bb88262 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -47,19 +47,20 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, - { - 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'] }, - }, + // { + // 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'] }, + // }, + ], }); diff --git a/src/api/osfApi.ts b/src/api/osfApi.ts index ba69af8..ddc4a15 100644 --- a/src/api/osfApi.ts +++ b/src/api/osfApi.ts @@ -848,3 +848,24 @@ export async function getRegistrationVolKey(registrationGuid: 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 47966a4..37c7208 100644 --- a/src/fixtures/index.ts +++ b/src/fixtures/index.ts @@ -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; @@ -54,6 +61,7 @@ type Fixtures = { defaultProjectWithTags: osfApi.OsfProject; publicLinkProject: osfApi.OsfProject; defaultProjectWithAllMetadata: osfApi.OsfProject; + defaultAddonsProject: osfApi.OsfProject; }; function getSessionCookieName(): string { @@ -63,12 +71,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. @@ -81,9 +89,10 @@ export const test = base.extend({ } await use(); }, - { auto: true }, + { auto: true, scope: 'worker' }, ], + fake: async ({}, use) => { await use(faker); }, @@ -93,7 +102,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) => { @@ -140,6 +149,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); @@ -367,6 +382,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..e8577ae --- /dev/null +++ b/src/pages/FilesPage.ts @@ -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 { + 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 { + // 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..bc0e6ca --- /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 gotoGuid(): 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/src/utils/index.ts b/src/utils/index.ts index 7e87c09..8a1c918 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,4 @@ -import { Page, Locator } from '@playwright/test'; +import { Page, Locator, Download } from '@playwright/test'; import * as settings from '../../config/settings'; @@ -149,6 +149,47 @@ export async function clickExpectingPopup(page: Page, locator: Locator): Promise return popup; } +/** + * Runs `action` and resolves with the download it triggers, whether the download starts in + * `page` itself or in a popup tab opened by it. OSF's download links open a new tab on some + * browsers (e.g. Edge), and Playwright emits `download` on that popup page - so a plain + * `page.waitForEvent('download')` on the original page never fires there. + */ +export async function waitForDownload( + page: Page, + action: () => Promise, + timeout: number = settings.TIMEOUT_MS +): Promise { + const context = page.context(); + let onPage: ((p: Page) => void) | undefined; + let onDownload: ((d: Download) => void) | undefined; + let timer: ReturnType | undefined; + const pages: Page[] = [page]; + + const downloadPromise = new Promise((resolve, reject) => { + onDownload = resolve; + onPage = (p: Page) => { + pages.push(p); + p.once('download', resolve); + }; + page.once('download', resolve); + context.on('page', onPage); + timer = setTimeout( + () => reject(new Error(`Timed out after ${timeout}ms waiting for a download in the page or a popup`)), + timeout + ); + }); + + try { + await action(); + return await downloadPromise; + } finally { + clearTimeout(timer); + context.off('page', onPage!); + for (const p of pages) p.off('download', onDownload!); + } +} + /** * Search-result card title links are matched by `.first()` on a generic * class-based locator that gets re-evaluated fresh at click time. Card-validation diff --git a/tests/projectFiles.spec.ts b/tests/projectFiles.spec.ts new file mode 100644 index 0000000..8f45f1b --- /dev/null +++ b/tests/projectFiles.spec.ts @@ -0,0 +1,405 @@ +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, waitForDownload } 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.gotoGuid(); + 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 download = await waitForDownload(page, () => downloadButton.click()); + + // 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()); +} + +const ROWS = 'osf-files-tree-row div.files-table-row'; + +/** Date format shown in the "last modified" column, e.g. "Aug 13, 2026 11:56 AM". */ +const DATE_FORMAT = /^[A-Z][a-z]{2} \d{1,2}, \d{4} \d{1,2}:\d{2} [AP]M$/; + +type Column = 'name' | 'modified'; +type Order = 'asc' | 'desc'; +type SortOption = { label: string; column: Column; order: Order }; + +const NAME_A_Z: SortOption = { label: 'Name: A-Z', column: 'name', order: 'asc' }; +const NAME_Z_A: SortOption = { label: 'Name: Z-A', column: 'name', order: 'desc' }; +const OLDEST_FIRST: SortOption = { label: 'Last modified: oldest to newest', column: 'modified', order: 'asc' }; +const NEWEST_FIRST: SortOption = { label: 'Last modified: newest to oldest', column: 'modified', order: 'desc' }; + +// `opposite` is selected first, so the test proves the dropdown really changes the order +// (the page is already sorted A-Z by default, which would otherwise pass without doing anything). +const SORT_CASES: { title: string; sort: SortOption; opposite: SortOption }[] = [ + { title: 'sort name A to Z', sort: NAME_A_Z, opposite: NAME_Z_A }, + { title: 'sort name Z to A', sort: NAME_Z_A, opposite: NAME_A_Z }, + // NOTE: titles kept from the Selenium test, even though they are swapped vs. what they select. + { title: 'sort modified date descending', sort: OLDEST_FIRST, opposite: NEWEST_FIRST }, + { title: 'sort modified date ascending', sort: NEWEST_FIRST, opposite: OLDEST_FIRST }, +]; + +// ---------- helpers ---------- + +type Row = { name: string; dateText: string }; + +/** Reads every row on the page: its name and its "last modified" text (empty for folders). */ +async function readRows(page: Page): Promise { + // Reads all rows in one go, so each name and date come from the same row + // and from the same moment (not half before and half after a re-render). + return page.locator(ROWS).evaluateAll((rows) => + rows.map((row) => { + const nameCell = row.querySelector('.entry-title') ?? row.querySelector(':scope > .table-cell'); + const cells = row.querySelectorAll(':scope > .files-table-cell'); + return { + name: (nameCell?.textContent ?? '').trim(), + dateText: (cells[2]?.textContent ?? '').trim(), // 3rd files-table-cell, as in the Selenium XPath + }; + }), + ); +} + +/** Case-insensitive name comparison, same as Python's sorted(key=str.lower). */ +function compareNames(a: string, b: string): number { + const x = a.toLowerCase(); + const y = b.toLowerCase(); + return x < y ? -1 : x > y ? 1 : 0; +} + +/** + * Checks any list of rows against a sort option, comparing each row with the next one. + * Equal values may be in either order. Rows without a date (folders) are ignored when + * sorting by date. Returns null when sorted, otherwise a description of the first problem. + */ +function findSortProblem(rows: Row[], sort: SortOption): string | null { + const items = + sort.column === 'name' + ? rows.map((r) => ({ label: r.name, value: r.name })) + : rows.filter((r) => r.dateText !== '').map((r) => ({ label: `${r.name} (${r.dateText})`, value: r.dateText })); + + for (let i = 1; i < items.length; i++) { + const prev = items[i - 1]; + const cur = items[i]; + let cmp: number; + if (sort.column === 'name') { + cmp = compareNames(prev.value, cur.value); + } else { + for (const item of [prev, cur]) { + if (!DATE_FORMAT.test(item.value)) return `not a date: "${item.label}" - check the date column locator`; + } + cmp = Date.parse(prev.value) - Date.parse(cur.value); + } + if (sort.order === 'asc' ? cmp > 0 : cmp < 0) { + return `"${prev.label}" is shown before "${cur.label}"`; + } + } + return null; +} + +/** Picks a sort option and retries until all `rowCount` rows are shown in that order. */ +async function sortAndVerify(page: Page, filesPage: { selectSortFromList(o: string): Promise }, sort: SortOption, rowCount: number) { + await filesPage.selectSortFromList(sort.label); + + // The rows on the page must be in this option's order + await expect + .poll( + async () => { + const rows = await readRows(page); + // Guard against reading a half-rendered list, which would look "sorted" + if (rows.length !== rowCount) return `expected ${rowCount} rows, found ${rows.length}`; + return findSortProblem(rows, sort) ?? 'sorted'; + }, + { message: `list should be sorted by "${sort.label}"` }, + ) + .toBe('sorted'); + + // Shows what the test actually saw, so you can compare it with the page while debugging + const rows = await readRows(page); + console.log(`[${sort.label}]\n` + rows.map((r) => ` ${r.name} | ${r.dateText || '-'}`).join('\n')); +} + + +// Example parameterized test replacing python's provider fixture +const UNSUPPORTED_PROVIDERS = ['bitbucket', 'dataverse', 'figshare', 'gitlab', 'onedrive']; +const providers = ['osfstorage', 's3', 'box', 'bitbucket', 'dataverse', 'dropbox', 'figshare', 'github', 'gitlab', 'googledrive', 'onedrive', 'owncloud']; +//const providers = ['googledrive']; + +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 }) => { + test.skip(UNSUPPORTED_PROVIDERS.includes(provider), 'Functionality not supported'); + // 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.selectFromAddonList(provider); + } + + // Trigger and verify download + + await verifyFileDownload(page, filesPage, fileName, provider) + + }); + } + + for (const provider of providers) { + test(`download a folder from ${provider}`, async ({ page, filesPage }) => { + test.skip(UNSUPPORTED_PROVIDERS.includes(provider), 'Functionality not supported'); + // 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 folderName = 'download_' + currentBrowserName + '_' + provider ; + + if (provider !== 'osfstorage') { + await filesPage.selectFromAddonList(provider); + } + + // Open the folder (search + click lives in the page object) + const filePath = path.join(os.homedir(), 'Downloads', folderName); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + + const rowPromise = findFolderBySearch(filesPage, folderName); + const row = await rowPromise; + if (!row) { + throw new Error(`Could not find row for file: ${folderName}`); + } + + // 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 download = await waitForDownload(page, () => downloadButton.click()); + + // Save it explicitly to a known path (or just verify via the Download object's own API) + const downloadFolderName = folderName+'.zip' + const downloadPath = path.join(os.homedir(), 'Downloads', downloadFolderName); + await download.saveAs(downloadPath); + + await filesPage.reload(); + + if (provider !== 'osfstorage') { + await filesPage.selectFromAddonList(provider); + } + + const currentDate = new Date(); + expect(fs.existsSync(downloadPath)).toBeTruthy(); + + const stats = fs.statSync(downloadPath); + const fileModDate = new Date(stats.mtime); + expect(fileModDate.toDateString()).toBe(currentDate.toDateString()); + + + }); + } + +}) + +for (const provider of providers) { + test(`download as a zip from ${provider}`, async ({ page, filesPage }) => { + test.skip(UNSUPPORTED_PROVIDERS.includes(provider), 'Functionality not supported'); + const currentBrowser = page.context().browser()?.browserType().name(); + const currentBrowserName: string = currentBrowser === 'chromium' ? 'chrome' : (currentBrowser ?? 'unknown'); + const folderName = 'download_' + currentBrowserName + '_' + provider; + + if (provider !== 'osfstorage') { + await filesPage.selectFromAddonList(provider); + } + + // Open the folder (search + click lives in the page object) + const filePath = path.join(os.homedir(), 'Downloads', folderName); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + + const rowPromise = findFolderBySearch(filesPage, folderName); + const row = await rowPromise; + if (!row) { + throw new Error(`Could not find row for file: ${folderName}`); + } + + // Click the Folder link to navagate inside the folder + await filesPage.clickOnFolderLink(folderName, row); + + + // The menu overlay renders outside the row (PrimeNG overlay), so scope to page, not row + const download = await waitForDownload(page, () => filesPage.clickOnButton('Download As Zip')); + + // Save it explicitly to a known path (or just verify via the Download object's own API) + const downloadFolderName = folderName + '.zip' + const downloadPath = path.join(os.homedir(), 'Downloads', downloadFolderName); + await download.saveAs(downloadPath); + + await filesPage.reload(); + + if (provider !== 'osfstorage') { + await filesPage.selectFromAddonList(provider); + } + + const currentDate = new Date(); + expect(fs.existsSync(downloadPath)).toBeTruthy(); + + const stats = fs.statSync(downloadPath); + const fileModDate = new Date(stats.mtime); + expect(fileModDate.toDateString()).toBe(currentDate.toDateString()); + + + }); +} + +for (const provider of providers) { + test(`top level download as zip from ${provider}`, async ({ page, filesPage, defaultAddonsProject }) => { + test.skip(UNSUPPORTED_PROVIDERS.includes(provider), 'Functionality not supported'); + // Substitute with your actual project ID or fixture login + //const node_id = defaultAddonsProject.id; + const currentBrowser = page.context().browser()?.browserType().name(); + const currentBrowserName: string = currentBrowser === 'chromium' ? 'chrome' : (currentBrowser ?? 'unknown'); + const folderName = provider + '-archive.zip' + + if (provider !== 'osfstorage') { + await filesPage.selectFromAddonList(provider); + } + + const download = await waitForDownload(page, () => filesPage.clickOnButton('Download As Zip')); + + // Save it explicitly to a known path (or just verify via the Download object's own API) + //const downloadFolderName = folderName + '.zip' + const downloadPath = path.join(os.homedir(), 'Downloads', folderName); + await download.saveAs(downloadPath); + + await filesPage.reload(); + + if (provider !== 'osfstorage') { + await filesPage.selectFromAddonList(provider); + } + + const currentDate = new Date(); + expect(fs.existsSync(downloadPath)).toBeTruthy(); + + const stats = fs.statSync(downloadPath); + const fileModDate = new Date(stats.mtime); + expect(fileModDate.toDateString()).toBe(currentDate.toDateString()); + + }); +} + +test.describe('Files Page Sort', () => { + for (const provider of providers) { + for (const { title, sort, opposite } of SORT_CASES) { + test(`${title} - ${provider}`, async ({ page, filesPage, defaultProject, session }) => { + test.skip(UNSUPPORTED_PROVIDERS.includes(provider), 'Functionality not supported'); + + if (provider !== 'osfstorage') { + await filesPage.selectFromAddonList(provider); + } + + // Replaces wait_until_page_ready: wait for the list, then count what is there + await expect(page.locator(ROWS).first()).toBeVisible(); + const rows = await readRows(page); + expect(rows.every((r) => r.name !== ''), 'a row has no name - check the name locator').toBe(true); + const comparable = sort.column === 'name' ? rows : rows.filter((r) => r.dateText !== ''); + expect(comparable.length, `need at least 2 ${sort.column === 'name' ? 'items' : 'dated files'} in ${provider}`).toBeGreaterThanOrEqual(2); + + await sortAndVerify(page, filesPage, opposite, rows.length); + await sortAndVerify(page, filesPage, sort, rows.length); + }); + } + } + +}); \ 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,