Skip to content

Commit a744485

Browse files
committed
Added delete project, delete file functionality. Added pageobject and small updates to versioning and skills.md
1 parent e2fd6c4 commit a744485

13 files changed

Lines changed: 681 additions & 357 deletions

File tree

.cursor/skills/tests/SKILL.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Page object inheritance:
4040
## Page Object Conventions
4141

4242
- When adding a new test, first check if an existing page object can be reused or extended before creating a new one.
43+
- Do not put UI flows (navigations, clicks, form fills) in `utils/test-helpers.ts`; move them into page objects.
4344
- All page objects must:
4445
- Extend the appropriate domain base page (`OpenProjectBasePage`, `NextcloudBasePage`, `KeycloakBasePage`) or `BasePage`.
4546
- Load locators via the base constructor using the correct locator JSON file.
@@ -108,12 +109,15 @@ try {
108109
## Test Helpers and Reuse
109110

110111
- Use functions for repetitive actions so they can be reused later rather than copied into each spec.
111-
- Shared helpers live primarily in:
112-
- `utils/test-helpers.ts` for high-level flows (e.g., `ensureProjectExists`, `ensureProjectHasNextcloudStorage`, `ensureDemoProjectCopyViaUi`).
113-
- `utils/openproject-api.ts` for direct API interactions with OpenProject (projects, users, storages).
112+
- **`utils/test-helpers.ts`** must contain only API-specific helpers and orchestration that combines API checks with page objects for UI fallback:
113+
- API helpers: `ensureUserIsAdmin`, `ensureProjectExists`, `getProjectStorages`, `ensureProjectHasNoNextcloudStorage`.
114+
- Orchestration: `ensureProjectHasNextcloudStorage` (API checks + optional UI via `OpenProjectProjectStoragesPage`).
115+
- **`utils/openproject-api.ts`** for direct API interactions with OpenProject (projects, users, storages).
116+
- **UI flows** belong in page objects, not test-helpers:
117+
- `OpenProjectHomePage.copyDemoProjectViaUi(newIdentifier)` – copy demo project via UI.
118+
- `OpenProjectProjectStoragesPage.navigateTo(identifier)`, `addNextcloudStorage()`, `hasNextcloudStorage()` – project storages UI.
114119
- When adding new cross-test flows:
115-
- First, see if existing helpers can be extended.
116-
- If needed, create a new helper function with a clear name and parameters.
120+
- Prefer page objects for UI flows; use test-helpers only for API or orchestration.
117121
- After UI actions that should succeed, verify the expected UI feedback:
118122
- Example: after adding Nextcloud storage from OpenProject, wait for the success banner (`storageCreationSuccessMessage` locator, text `"Successful creation."`).
119123

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,12 +137,13 @@ import { ensureProjectHasNextcloudStorage } from '../utils/test-helpers';
137137
await ensureProjectHasNextcloudStorage('demo-project');
138138
```
139139

140-
- Copy the demo project via UI using the existing page objects:
140+
- Copy the demo project via UI using page objects:
141141

142142
```typescript
143-
import { ensureDemoProjectCopyViaUi } from '../utils/test-helpers';
143+
import { OpenProjectHomePage } from '../pageobjects/openproject';
144144

145-
await ensureDemoProjectCopyViaUi(page, 'test');
145+
const homePage = new OpenProjectHomePage(page);
146+
await homePage.copyDemoProjectViaUi('test');
146147
```
147148

148149
## Environment Variables

global-setup.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { FullConfig } from '@playwright/test';
22
import { waitForSetupJobComplete, setupJobExists, isSetupJobComplete } from './utils/pod-waiter';
33
import { detectAllVersions } from './utils/version-detect';
4+
import { ensureKeycloakDirectAccessForNextcloud } from './utils/nextcloud-api';
45
import { getErrorMessage } from './utils/error-utils';
56
import { logInfo, logError, logWarn } from './utils/logger';
67

@@ -69,6 +70,15 @@ async function globalSetup(config: FullConfig) {
6970
logWarn('⚠️ Version detection failed:', getErrorMessage(error));
7071
logWarn(' Tests will use "not-detected" as fallback.');
7172
}
73+
74+
// ── Step 3: Enable direct access grants for Nextcloud WebDAV ────────
75+
try {
76+
await ensureKeycloakDirectAccessForNextcloud();
77+
logInfo('✓ Keycloak direct access grants enabled for Nextcloud WebDAV');
78+
} catch (error: unknown) {
79+
logWarn('⚠️ Failed to enable Keycloak direct access grants:', getErrorMessage(error));
80+
logWarn(' Nextcloud WebDAV operations may fail.');
81+
}
7282
}
7383

7484
export default globalSetup;

pageobjects/openproject/OpenProjectHomePage.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,15 @@ export class OpenProjectHomePage extends OpenProjectBasePage {
108108
]);
109109
}
110110

111+
/**
112+
* Copy the demo project via UI: waits for home, navigates to all projects, copies demo project to the given identifier.
113+
*/
114+
async copyDemoProjectViaUi(newIdentifier: string): Promise<void> {
115+
await this.waitForReady();
116+
await this.navigateToAllProjects();
117+
await this.copyDemoProjectTo(newIdentifier);
118+
}
119+
111120
async copyDemoProjectTo(name: string): Promise<void> {
112121
const demoProjectKebabButton = this.getLocator('demoProjectKebabButton').first();
113122
await demoProjectKebabButton.waitFor({ state: 'visible', timeout: 15000 });
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { Page } from '@playwright/test';
2+
import { OpenProjectBasePage } from './OpenProjectBasePage';
3+
4+
/**
5+
* Page object for a project's external file storages settings page.
6+
* Supports adding Nextcloud storage via the UI.
7+
*/
8+
export class OpenProjectProjectStoragesPage extends OpenProjectBasePage {
9+
constructor(page: Page) {
10+
super(page);
11+
}
12+
13+
/**
14+
* Navigate to a project's external file storages page.
15+
*/
16+
async navigateToProjectStorages(projectIdentifier: string): Promise<void> {
17+
await this.navigateToProjectStoragesExternal(projectIdentifier);
18+
const escaped = projectIdentifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
19+
const pattern = new RegExp(
20+
`.*/projects/${escaped}/settings/project_storages/external_file_storages.*`
21+
);
22+
await this.page.waitForURL(pattern, { timeout: 15000 });
23+
}
24+
25+
/**
26+
* Check if Nextcloud storage row is already present on the page.
27+
* Assumes we are on the project storages external page.
28+
*/
29+
async hasNextcloudStorage(): Promise<boolean> {
30+
const nextcloudStorageRow = this.getLocator('nextcloudStorageRow');
31+
const count = await nextcloudStorageRow.count();
32+
return count > 0;
33+
}
34+
35+
/**
36+
* Add Nextcloud storage to the current project via the UI.
37+
* Assumes we are on the project storages external page and storage is not yet linked.
38+
*/
39+
async addNextcloudStorage(): Promise<void> {
40+
const newStorageLink = this.getLocator('newStorageLink');
41+
await newStorageLink.waitFor({ state: 'visible', timeout: 10000 });
42+
await newStorageLink.click();
43+
await this.page.waitForURL(/\/projects\/[^/]+\/settings\/project_storages\/new/, {
44+
timeout: 15000,
45+
});
46+
47+
const addFileStorageHeading = this.getLocator('addFileStorageHeading').first();
48+
await addFileStorageHeading.waitFor({ state: 'visible', timeout: 10000 });
49+
50+
const storageDropdown = this.getLocator('storageDropdown');
51+
await storageDropdown.waitFor({ state: 'visible', timeout: 10000 });
52+
53+
const selectedOption = storageDropdown.locator('option:checked');
54+
const selectedText = (await selectedOption.textContent())?.toLowerCase() ?? '';
55+
if (!selectedText.includes('nextcloud')) {
56+
const nextcloudOption = storageDropdown.locator('option', { hasText: /nextcloud/i }).first();
57+
if ((await nextcloudOption.count()) === 0) {
58+
throw new Error('Nextcloud option is not available in the storage dropdown.');
59+
}
60+
const nextcloudValue = await nextcloudOption.getAttribute('value');
61+
if (!nextcloudValue) {
62+
throw new Error('Nextcloud option is missing a value attribute.');
63+
}
64+
await storageDropdown.selectOption(nextcloudValue);
65+
}
66+
67+
const continueButton = this.getLocator('continueButton');
68+
await continueButton.waitFor({ state: 'visible', timeout: 10000 });
69+
await continueButton.click();
70+
71+
const automaticFolderModeRadio = this.getLocator('automaticFolderModeRadio');
72+
await automaticFolderModeRadio.waitFor({ state: 'visible', timeout: 10000 });
73+
if (!(await automaticFolderModeRadio.isChecked())) {
74+
await automaticFolderModeRadio.check();
75+
}
76+
77+
const addButton = this.getLocator('addButton');
78+
await addButton.waitFor({ state: 'visible', timeout: 10000 });
79+
await addButton.click();
80+
81+
const successMessage = this.getLocator('storageCreationSuccessMessage');
82+
await successMessage.waitFor({ state: 'visible', timeout: 15000 });
83+
}
84+
}

pageobjects/openproject/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { OpenProjectBasePage } from './OpenProjectBasePage';
22
export { OpenProjectLoginPage } from './OpenProjectLoginPage';
33
export { OpenProjectHomePage } from './OpenProjectHomePage';
4+
export { OpenProjectProjectStoragesPage } from './OpenProjectProjectStoragesPage';
45

tests/base-test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { test as base, expect } from '@playwright/test';
2+
import { Page } from '@playwright/test';
3+
import { testConfig } from '../utils/config';
4+
5+
export const test = base.extend({});
6+
7+
test.beforeEach(async ({ page }, testInfo) => {
8+
const versions = [
9+
{ type: 'openproject_version', description: `OpenProject: ${testConfig.openproject.version}` },
10+
{ type: 'nextcloud_version', description: `Nextcloud: ${testConfig.nextcloud.version}` },
11+
{ type: 'nc_api_version', description: `NC API: ${testConfig.nextcloud.apiVersion}` },
12+
{ type: 'integration_app', description: `Integration App: ${testConfig.nextcloud.integrationAppVersion}` },
13+
{ type: 'team_folders', description: `Team Folders: ${testConfig.nextcloud.teamFoldersVersion}` },
14+
{ type: 'keycloak_version', description: `Keycloak: ${testConfig.keycloak.version}` },
15+
];
16+
for (const ann of versions) {
17+
testInfo.annotations.push(ann);
18+
}
19+
20+
attachDebugListeners(page);
21+
});
22+
23+
export function attachDebugListeners(page: Page): void {
24+
page.on('framenavigated', (frame) => {
25+
if (frame === page.mainFrame()) {
26+
console.log(`[PAGE NAVIGATION] Frame navigated to: ${frame.url()}`);
27+
}
28+
});
29+
page.on('request', (request) => {
30+
console.log(`[NETWORK REQUEST] ${request.method()} ${request.url()}`);
31+
});
32+
page.on('response', (response) => {
33+
console.log(`[NETWORK RESPONSE] ${response.status()} ${response.url()}`);
34+
});
35+
page.on('console', (msg) => {
36+
console.log(`[PAGE CONSOLE] ${msg.type()}: ${msg.text()}`);
37+
});
38+
}
39+
40+
/**
41+
* Build a URL regex for a given host and path.
42+
* Host can be a hostname (e.g. "openproject.test") or a full URL.
43+
*/
44+
export function urlForHost(path: string, host: string): RegExp {
45+
const escapeForRegex = (value: string): string =>
46+
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
47+
const resolveHostname = (value: string): string => {
48+
try {
49+
return new URL(value).hostname;
50+
} catch {
51+
return value;
52+
}
53+
};
54+
const escapedHost = escapeForRegex(resolveHostname(host));
55+
const cleanPath = path.startsWith('/') ? path : `/${path}`;
56+
const pathPattern = cleanPath.endsWith('/')
57+
? cleanPath.slice(0, -1) + '/?'
58+
: cleanPath + '/?';
59+
return new RegExp(`^https?://${escapedHost}${pathPattern}$`);
60+
}
61+
62+
export const openProjectUrl = (path: string) =>
63+
urlForHost(path, testConfig.openproject.host);
64+
export const nextcloudUrl = (path: string) =>
65+
urlForHost(path, testConfig.nextcloud.host);
66+
export const keycloakUrl = (path: string) =>
67+
urlForHost(path, testConfig.keycloak.host);
68+
69+
export const integrationTags = { tag: ['@regression', '@integration'] };
70+
71+
export { expect };

tests/opncintegration/kc-integration.spec.ts

Lines changed: 2 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,7 @@
1-
import { test, expect } from '@playwright/test';
1+
import { test, expect, integrationTags } from '../base-test';
22
import { KeycloakLoginPage, KeycloakHomePage } from '../../pageobjects/keycloak';
3-
import { testConfig } from '../../utils/config';
4-
5-
/**
6-
* Build a URL regex for the configured Keycloak host and a given path.
7-
*/
8-
function keycloakUrl(path: string): RegExp {
9-
const escapeForRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
10-
const resolveHostname = (value: string): string => {
11-
try {
12-
return new URL(value).hostname;
13-
} catch {
14-
return value;
15-
}
16-
};
17-
const host = escapeForRegex(resolveHostname(testConfig.keycloak.host));
18-
const cleanPath = path.startsWith('/') ? path : `/${path}`;
19-
const pathPattern = cleanPath.endsWith('/') ? cleanPath.slice(0, -1) + '/?' : cleanPath + '/?';
20-
return new RegExp(`^https?://${host}${pathPattern}$`);
21-
}
22-
23-
test.describe('SSO External - Keycloak Integration', { tag: ['@regression', '@integration'] }, () => {
24-
test.beforeEach(async ({ page }) => {
25-
page.on('framenavigated', (frame) => {
26-
if (frame === page.mainFrame()) {
27-
console.log(`[PAGE NAVIGATION] Frame navigated to: ${frame.url()}`);
28-
}
29-
});
30-
31-
page.on('request', (request) => {
32-
console.log(`[NETWORK REQUEST] ${request.method()} ${request.url()}`);
33-
});
34-
35-
page.on('response', (response) => {
36-
console.log(`[NETWORK RESPONSE] ${response.status()} ${response.url()}`);
37-
});
38-
39-
page.on('console', (msg) => {
40-
console.log(`[PAGE CONSOLE] ${msg.type()}: ${msg.text()}`);
41-
});
42-
});
433

4+
test.describe('SSO External - Keycloak Integration', integrationTags, () => {
445
test('should login to Keycloak and check op and nc client are present', { tag: ['@smoke'] }, async ({ page }) => {
456
const loginPage = new KeycloakLoginPage(page);
467
await loginPage.login();

tests/opncintegration/nc-integration.spec.ts

Lines changed: 2 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,7 @@
1-
import { test, expect } from '@playwright/test';
1+
import { test, expect, integrationTags } from '../base-test';
22
import { NextcloudLoginPage, NextcloudActiveAppsPage, NextcloudOpenIDConnectPage } from '../../pageobjects/nextcloud';
3-
import { testConfig } from '../../utils/config';
4-
5-
/**
6-
* Build a URL regex for the configured Nextcloud host and a given path.
7-
*/
8-
function nextcloudUrl(path: string): RegExp {
9-
const escapeForRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
10-
const resolveHostname = (value: string): string => {
11-
try {
12-
return new URL(value).hostname;
13-
} catch {
14-
return value;
15-
}
16-
};
17-
const host = escapeForRegex(resolveHostname(testConfig.nextcloud.host));
18-
const cleanPath = path.startsWith('/') ? path : `/${path}`;
19-
const pathPattern = cleanPath.endsWith('/') ? cleanPath.slice(0, -1) + '/?' : cleanPath + '/?';
20-
return new RegExp(`^https?://${host}${pathPattern}$`);
21-
}
22-
23-
test.describe('SSO External - Nextcloud Integration', { tag: ['@regression', '@integration'] }, () => {
24-
test.beforeEach(async ({ page }) => {
25-
page.on('framenavigated', (frame) => {
26-
if (frame === page.mainFrame()) {
27-
console.log(`[PAGE NAVIGATION] Frame navigated to: ${frame.url()}`);
28-
}
29-
});
30-
31-
page.on('request', (request) => {
32-
console.log(`[NETWORK REQUEST] ${request.method()} ${request.url()}`);
33-
});
34-
35-
page.on('response', (response) => {
36-
console.log(`[NETWORK RESPONSE] ${response.status()} ${response.url()}`);
37-
});
38-
39-
page.on('console', (msg) => {
40-
console.log(`[PAGE CONSOLE] ${msg.type()}: ${msg.text()}`);
41-
});
42-
});
433

4+
test.describe('SSO External - Nextcloud Integration', integrationTags, () => {
445
test('should login to Nextcloud and verify Keycloak provider details', async ({ page }) => {
456
const loginPage = new NextcloudLoginPage(page);
467
const dashboardPage = await loginPage.login('admin', 'admin');
@@ -67,12 +28,6 @@ test.describe('SSO External - Nextcloud Integration', { tag: ['@regression', '@i
6728
await activeAppsPage.waitForReady();
6829
await activeAppsPage.findOpenProjectIntegrationApp();
6930
const appVersion = await activeAppsPage.getOpenProjectIntegrationAppVersion();
70-
const originalTitle = test.info().title;
71-
test.info().title = `${originalTitle} (v${appVersion})`;
72-
test.info().annotations.push({
73-
type: 'app_version',
74-
description: `OpenProject Integration App Version: ${appVersion}`
75-
});
7631

7732
console.log(`[TEST RESULT] OpenProject Integration App Version: ${appVersion}`);
7833
const appLink = activeAppsPage.getOpenProjectIntegrationAppLink();

0 commit comments

Comments
 (0)