diff --git a/.github/workflows/e2e-playwright-reusable.yml b/.github/workflows/e2e-playwright-reusable.yml
index 5e0f9761c..ba26846f1 100644
--- a/.github/workflows/e2e-playwright-reusable.yml
+++ b/.github/workflows/e2e-playwright-reusable.yml
@@ -114,6 +114,8 @@ jobs:
id: 19
- name: "viewer-actions"
id: 20
+ - name: "create-link-actions"
+ id: 21
steps:
- name: Free hosted runner disk space
uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@10cea6c0b390c4f8af21d9dc1670d581bd10319f # v18.13.0
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index b930f0925..c4a89560c 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -187,6 +187,8 @@ jobs:
id: 19
- name: "viewer-actions"
id: 20
+ - name: "create-link-actions"
+ id: 21
steps:
- name: Free hosted runner disk space
uses: Alfresco/alfresco-build-tools/.github/actions/free-hosted-runner-disk-space@10cea6c0b390c4f8af21d9dc1670d581bd10319f # v18.13.0
diff --git a/.github/workflows/run-e2e-with-env.yml b/.github/workflows/run-e2e-with-env.yml
index ef8498c64..2083bf490 100644
--- a/.github/workflows/run-e2e-with-env.yml
+++ b/.github/workflows/run-e2e-with-env.yml
@@ -103,6 +103,10 @@ jobs:
id: 18
- name: "folder-information-actions"
id: 19
+ - name: "viewer-actions"
+ id: 20
+ - name: "create-link-actions"
+ id: 21
steps:
- name: Checkout
uses: actions/checkout@v7
diff --git a/e2e/playwright/create-link-actions/.eslintrc.json b/e2e/playwright/create-link-actions/.eslintrc.json
new file mode 100644
index 000000000..ea0f5c9b0
--- /dev/null
+++ b/e2e/playwright/create-link-actions/.eslintrc.json
@@ -0,0 +1,22 @@
+{
+ "extends": "../../../.eslintrc.json",
+ "ignorePatterns": [
+ "!**/*"
+ ],
+ "overrides": [
+ {
+ "files": [
+ "*.ts"
+ ],
+ "extends": ["../../../.eslintrc.e2e.json"],
+ "parserOptions": {
+ "project": [
+ "e2e/playwright/create-link-actions/tsconfig.e2e.json"
+ ]
+ },
+ "rules": {
+ "@typescript-eslint/no-floating-promises": "off"
+ }
+ }
+ ]
+}
diff --git a/e2e/playwright/create-link-actions/exclude.tests.json b/e2e/playwright/create-link-actions/exclude.tests.json
new file mode 100644
index 000000000..7507194dc
--- /dev/null
+++ b/e2e/playwright/create-link-actions/exclude.tests.json
@@ -0,0 +1,3 @@
+{
+ "all": {}
+}
diff --git a/e2e/playwright/create-link-actions/playwright.config.ts b/e2e/playwright/create-link-actions/playwright.config.ts
new file mode 100644
index 000000000..f9d5f1793
--- /dev/null
+++ b/e2e/playwright/create-link-actions/playwright.config.ts
@@ -0,0 +1,36 @@
+/*!
+ * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see .
+ */
+
+import { PlaywrightTestConfig } from '@playwright/test';
+import { CustomConfig, getGlobalConfig, getExcludedTestsRegExpArray, timeouts, createSuiteProjects } from '@alfresco/aca-playwright-shared';
+import EXCLUDED_JSON from './exclude.tests.json';
+
+const config: PlaywrightTestConfig = {
+ ...getGlobalConfig,
+
+ grepInvert: getExcludedTestsRegExpArray(EXCLUDED_JSON, 'Create Link Actions'),
+ projects: createSuiteProjects('Create Link Actions', './src/tests', {}, { timeout: timeouts.extendedTest })
+};
+
+export default config;
diff --git a/e2e/playwright/create-link-actions/project.json b/e2e/playwright/create-link-actions/project.json
new file mode 100644
index 000000000..a45180294
--- /dev/null
+++ b/e2e/playwright/create-link-actions/project.json
@@ -0,0 +1,131 @@
+{
+ "name": "create-link-actions-e2e",
+ "$schema": "../../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "e2e/playwright/create-link-actions/src",
+ "projectType": "application",
+ "targets": {
+ "e2e": {
+ "executor": "nx:run-commands",
+ "options": {
+ "commands": [
+ "playwright test --config=e2e/playwright/create-link-actions/playwright.config.ts"
+ ]
+ },
+ "configurations": {
+ "ui": {
+ "args": [
+ "--ui"
+ ]
+ },
+ "debug": {
+ "args": [
+ "--debug"
+ ]
+ },
+ "headed": {
+ "args": [
+ "--headed"
+ ]
+ }
+ }
+ },
+ "lint": {
+ "executor": "@angular-eslint/builder:lint"
+ },
+ "e2e-multibrowser": {
+ "executor": "nx:run-commands",
+ "options": {},
+ "configurations": {
+ "webkit-headed": {
+ "env": {
+ "PLAYWRIGHT_BROWSER": "webkit"
+ },
+ "args": [
+ "--headed"
+ ],
+ "commands": [
+ "playwright test --config=e2e/playwright/create-link-actions/playwright.config.ts"
+ ]
+ },
+ "webkit-headless": {
+ "env": {
+ "PLAYWRIGHT_BROWSER": "webkit",
+ "PLAYWRIGHT_HEADLESS": "true"
+ },
+ "commands": [
+ "playwright test --config=e2e/playwright/create-link-actions/playwright.config.ts"
+ ]
+ },
+ "firefox-headed": {
+ "env": {
+ "PLAYWRIGHT_BROWSER": "firefox"
+ },
+ "args": [
+ "--headed"
+ ],
+ "commands": [
+ "playwright test --config=e2e/playwright/create-link-actions/playwright.config.ts"
+ ]
+ },
+ "firefox-headless": {
+ "env": {
+ "PLAYWRIGHT_BROWSER": "firefox",
+ "PLAYWRIGHT_HEADLESS": "true"
+ },
+ "commands": [
+ "playwright test --config=e2e/playwright/create-link-actions/playwright.config.ts"
+ ]
+ },
+ "chrome-headed": {
+ "env": {
+ "PLAYWRIGHT_BROWSER": "chrome"
+ },
+ "args": [
+ "--headed"
+ ],
+ "commands": [
+ "playwright test --config=e2e/playwright/create-link-actions/playwright.config.ts"
+ ]
+ },
+ "chrome-headless": {
+ "env": {
+ "PLAYWRIGHT_BROWSER": "chrome",
+ "PLAYWRIGHT_HEADLESS": "true"
+ },
+ "commands": [
+ "playwright test --config=e2e/playwright/create-link-actions/playwright.config.ts"
+ ]
+ },
+ "msedge-headed": {
+ "env": {
+ "PLAYWRIGHT_BROWSER": "msedge"
+ },
+ "args": [
+ "--headed"
+ ],
+ "commands": [
+ "playwright test --config=e2e/playwright/create-link-actions/playwright.config.ts"
+ ]
+ },
+ "msedge-headless": {
+ "env": {
+ "PLAYWRIGHT_BROWSER": "msedge",
+ "PLAYWRIGHT_HEADLESS": "true"
+ },
+ "commands": [
+ "playwright test --config=e2e/playwright/create-link-actions/playwright.config.ts"
+ ]
+ },
+ "all-browsers-headless": {
+ "env": {
+ "PLAYWRIGHT_BROWSER": "all",
+ "PLAYWRIGHT_HEADLESS": "true"
+ },
+ "commands": [
+ "playwright test --config=e2e/playwright/create-link-actions/playwright.config.ts"
+ ]
+ }
+ }
+ }
+ }
+}
diff --git a/e2e/playwright/create-link-actions/src/tests/create-link.e2e.ts b/e2e/playwright/create-link-actions/src/tests/create-link.e2e.ts
new file mode 100644
index 000000000..25b821027
--- /dev/null
+++ b/e2e/playwright/create-link-actions/src/tests/create-link.e2e.ts
@@ -0,0 +1,134 @@
+/*!
+ * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see .
+ */
+
+import { ApiClientFactory, NodesApi, TrashcanApi, Utils, test, LoginPage, PersonalFilesPage } from '@alfresco/aca-playwright-shared';
+import { expect } from '@playwright/test';
+
+test.describe('Create Link - creation scenarios', () => {
+ let nodesApi: NodesApi;
+ let trashcanApi: TrashcanApi;
+ const username = `user-${Utils.random()}`;
+
+ let sourceFile: string;
+ let sourceFolder: string;
+ let destinationFolder: string;
+
+ let sourceFileId: string;
+ let destinationFolderId: string;
+
+ test.beforeAll(async () => {
+ try {
+ const apiClientFactory = new ApiClientFactory();
+ await apiClientFactory.setUpAcaBackend('admin');
+ await apiClientFactory.createUser({ username });
+ nodesApi = await NodesApi.initialize(username, username);
+ trashcanApi = await TrashcanApi.initialize(username, username);
+ } catch (error) {
+ console.error(`beforeAll failed: ${error}`);
+ throw error;
+ }
+ });
+
+ test.beforeEach(async ({ personalFiles, page }) => {
+ sourceFile = `source-file-${Utils.random()}.txt`;
+ sourceFolder = `source-folder-${Utils.random()}`;
+ destinationFolder = `destination-folder-${Utils.random()}`;
+
+ const loginPage = new LoginPage(page);
+ await Utils.tryLoginUser(loginPage, username, username, 'beforeEach failed');
+
+ sourceFileId = (await nodesApi.createFile(sourceFile)).entry.id;
+ await nodesApi.createFolder(sourceFolder);
+ destinationFolderId = (await nodesApi.createFolder(destinationFolder)).entry.id;
+
+ await personalFiles.navigate();
+ });
+
+ test.afterAll(async () => {
+ await Utils.deleteNodesSitesEmptyTrashcan(nodesApi, trashcanApi, 'afterAll failed');
+ });
+
+ test('[XAT-19624] Should create a link for a file and verify it in the destination', async ({ personalFiles }) => {
+ await Utils.reloadPageIfRowNotVisible(personalFiles, sourceFile);
+ await createLinkInPersonalFiles(personalFiles, [sourceFile], destinationFolder);
+
+ const msg = await personalFiles.snackBar.getSnackBarMessage();
+ expect(msg).toContain('Link created for 1 item');
+
+ await personalFiles.navigate({ remoteUrl: `./#/personal-files/${destinationFolderId}`, waitUntil: 'load' });
+ await personalFiles.spinnerWaitForReload();
+
+ const expectedLinkName = `Link to ${sourceFile}.url`;
+ expect(await personalFiles.dataTable.isItemPresent(expectedLinkName)).toBe(true);
+ });
+
+ test('[XAT-19625] Should create a link for a folder and verify it in the destination', async ({ personalFiles }) => {
+ await Utils.reloadPageIfRowNotVisible(personalFiles, sourceFolder);
+ await createLinkInPersonalFiles(personalFiles, [sourceFolder], destinationFolder);
+
+ const msg = await personalFiles.snackBar.getSnackBarMessage();
+ expect(msg).toContain('Link created for 1 item');
+
+ await personalFiles.navigate({ remoteUrl: `./#/personal-files/${destinationFolderId}`, waitUntil: 'load' });
+ await personalFiles.spinnerWaitForReload();
+
+ const expectedLinkName = `Link to ${sourceFolder}.url`;
+ expect(await personalFiles.dataTable.isItemPresent(expectedLinkName)).toBe(true);
+ });
+
+ test('[XAT-19626] Should create links for multiple files and folders simultaneously', async ({ personalFiles }) => {
+ await Utils.reloadPageIfRowNotVisible(personalFiles, sourceFolder);
+ await createLinkInPersonalFiles(personalFiles, [sourceFile, sourceFolder], destinationFolder);
+
+ const msg = await personalFiles.snackBar.getSnackBarMessage();
+ expect(msg).toContain('Links created for 2 items');
+
+ await personalFiles.navigate({ remoteUrl: `./#/personal-files/${destinationFolderId}`, waitUntil: 'load' });
+ await personalFiles.spinnerWaitForReload();
+
+ expect(await personalFiles.dataTable.isItemPresent(`Link to ${sourceFile}.url`)).toBe(true);
+ expect(await personalFiles.dataTable.isItemPresent(`Link to ${sourceFolder}.url`)).toBe(true);
+ });
+
+ test('[XAT-19627] Should not allow creating a duplicate link in the same destination', async ({ personalFiles }) => {
+ await nodesApi.createFileLink(sourceFileId, destinationFolderId);
+ await Utils.reloadPageIfRowNotVisible(personalFiles, sourceFile);
+
+ await personalFiles.dataTable.selectItems(sourceFile);
+ await personalFiles.clickMoreActionsButton('Create Link');
+ await personalFiles.contentNodeSelector.selectDestination(destinationFolder);
+ await personalFiles.contentNodeSelector.actionButton.click();
+
+ const msg = await personalFiles.snackBar.getSnackBarMessage();
+ expect(msg).toContain("Can't create a link, a link with the same name already exists in this location.");
+ });
+
+ const createLinkInPersonalFiles = async (personalFilesPage: PersonalFilesPage, itemNames: string[], destinationName: string): Promise => {
+ await personalFilesPage.dataTable.selectItems(...itemNames);
+ await personalFilesPage.clickMoreActionsButton('Create Link');
+ await personalFilesPage.contentNodeSelector.selectDestination(destinationName);
+ await personalFilesPage.contentNodeSelector.actionButton.click();
+ await personalFilesPage.spinnerWaitForReload();
+ };
+});
diff --git a/e2e/playwright/create-link-actions/src/tests/link-actions.e2e.ts b/e2e/playwright/create-link-actions/src/tests/link-actions.e2e.ts
new file mode 100644
index 000000000..859a256a9
--- /dev/null
+++ b/e2e/playwright/create-link-actions/src/tests/link-actions.e2e.ts
@@ -0,0 +1,287 @@
+/*!
+ * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see .
+ */
+
+import { ApiClientFactory, NodesApi, TrashcanApi, Utils, test, LoginPage, PersonalFilesPage } from '@alfresco/aca-playwright-shared';
+import { expect } from '@playwright/test';
+
+test.describe('Create Link - actions on linked items', () => {
+ let nodesApi: NodesApi;
+ let trashcanApi: TrashcanApi;
+ const username = `user-${Utils.random()}`;
+
+ let sourceFile: string;
+ let sourceFolder: string;
+ let destinationFolder: string;
+ let copyDestinationFolder: string;
+ let moveDestinationFolder: string;
+
+ let sourceFileId: string;
+ let sourceFolderId: string;
+ let destinationFolderId: string;
+ let copyDestinationFolderId: string;
+ let moveDestinationFolderId: string;
+
+ let fileLinkName: string;
+ let folderLinkName: string;
+
+ test.beforeAll(async () => {
+ try {
+ const apiClientFactory = new ApiClientFactory();
+ await apiClientFactory.setUpAcaBackend('admin');
+ await apiClientFactory.createUser({ username });
+ nodesApi = await NodesApi.initialize(username, username);
+ trashcanApi = await TrashcanApi.initialize(username, username);
+ } catch (error) {
+ console.error(`beforeAll failed: ${error}`);
+ throw error;
+ }
+ });
+
+ test.beforeEach(async ({ personalFiles, page }) => {
+ sourceFile = `source-file-${Utils.random()}.txt`;
+ sourceFolder = `source-folder-${Utils.random()}`;
+ destinationFolder = `destination-folder-${Utils.random()}`;
+ copyDestinationFolder = `copy-dest-${Utils.random()}`;
+ moveDestinationFolder = `move-dest-${Utils.random()}`;
+
+ const loginPage = new LoginPage(page);
+ await Utils.tryLoginUser(loginPage, username, username, 'beforeEach failed');
+
+ sourceFileId = (await nodesApi.createFile(sourceFile)).entry.id;
+ sourceFolderId = (await nodesApi.createFolder(sourceFolder)).entry.id;
+ destinationFolderId = (await nodesApi.createFolder(destinationFolder)).entry.id;
+ copyDestinationFolderId = (await nodesApi.createFolder(copyDestinationFolder)).entry.id;
+ moveDestinationFolderId = (await nodesApi.createFolder(moveDestinationFolder)).entry.id;
+
+ const fileLinkEntry = await nodesApi.createFileLink(sourceFileId, destinationFolderId);
+ const folderLinkEntry = await nodesApi.createFolderLink(sourceFolderId, destinationFolderId);
+
+ fileLinkName = fileLinkEntry.entry.name;
+ folderLinkName = folderLinkEntry.entry.name;
+
+ await personalFiles.navigate();
+ await Utils.reloadPageIfRowNotVisible(personalFiles, destinationFolder);
+ });
+
+ test.afterAll(async () => {
+ await Utils.deleteNodesSitesEmptyTrashcan(nodesApi, trashcanApi, 'afterAll failed');
+ });
+
+ test('[XAT-19628] Should copy a linked file to another location and verify the link still exists', async ({ personalFiles }) => {
+ await navigateToFolderById(personalFiles, destinationFolderId);
+ await Utils.reloadPageIfRowNotVisible(personalFiles, fileLinkName);
+
+ await copyItemInPage(personalFiles, fileLinkName, copyDestinationFolder);
+
+ const msg = await personalFiles.snackBar.getSnackBarMessage();
+ expect(msg).toContain('Copied 1 item');
+
+ expect(await personalFiles.dataTable.isItemPresent(fileLinkName)).toBe(true);
+
+ await navigateToFolderById(personalFiles, copyDestinationFolderId);
+ expect(await personalFiles.dataTable.isItemPresent(fileLinkName)).toBe(true);
+ });
+
+ test('[XAT-19629] Should copy a linked folder to another location and verify the link still exists', async ({ personalFiles }) => {
+ await navigateToFolderById(personalFiles, destinationFolderId);
+ await Utils.reloadPageIfRowNotVisible(personalFiles, folderLinkName);
+
+ await copyItemInPage(personalFiles, folderLinkName, copyDestinationFolder);
+
+ const msg = await personalFiles.snackBar.getSnackBarMessage();
+ expect(msg).toContain('Copied 1 item');
+
+ expect(await personalFiles.dataTable.isItemPresent(folderLinkName)).toBe(true);
+
+ await navigateToFolderById(personalFiles, copyDestinationFolderId);
+ expect(await personalFiles.dataTable.isItemPresent(folderLinkName)).toBe(true);
+ });
+
+ test('[XAT-19630] Should move a linked file to another location and verify the link is accessible', async ({ personalFiles }) => {
+ await navigateToFolderById(personalFiles, destinationFolderId);
+ await Utils.reloadPageIfRowNotVisible(personalFiles, fileLinkName);
+
+ await moveItemInPage(personalFiles, fileLinkName, moveDestinationFolder);
+
+ const msg = await personalFiles.snackBar.getSnackBarMessage();
+ expect(msg).toContain('Moved 1 item.');
+ await personalFiles.snackBar.closeIcon.click();
+
+ expect(await personalFiles.dataTable.isItemPresent(fileLinkName)).toBe(false);
+
+ await navigateToFolderById(personalFiles, moveDestinationFolderId);
+ expect(await personalFiles.dataTable.isItemPresent(fileLinkName)).toBe(true);
+ });
+
+ test('[XAT-19631] Should move a linked folder to another location and verify the link is accessible', async ({ personalFiles }) => {
+ await navigateToFolderById(personalFiles, destinationFolderId);
+ await Utils.reloadPageIfRowNotVisible(personalFiles, folderLinkName);
+
+ await moveItemInPage(personalFiles, folderLinkName, moveDestinationFolder);
+
+ const msg = await personalFiles.snackBar.getSnackBarMessage();
+ expect(msg).toContain('Moved 1 item.');
+ await personalFiles.snackBar.closeIcon.click();
+
+ expect(await personalFiles.dataTable.isItemPresent(folderLinkName)).toBe(false);
+
+ await navigateToFolderById(personalFiles, moveDestinationFolderId);
+ expect(await personalFiles.dataTable.isItemPresent(folderLinkName)).toBe(true);
+ });
+
+ test('[XAT-19632] Should permanently delete a linked file without showing Undo or placing it in Trash', async ({ personalFiles, trashPage }) => {
+ await navigateToFolderById(personalFiles, destinationFolderId);
+ await Utils.reloadPageIfRowNotVisible(personalFiles, fileLinkName);
+
+ const deleteAction = await personalFiles.dataTable.getActionLocatorFromExpandableMenu(fileLinkName, 'Delete');
+ await deleteAction.click();
+ await personalFiles.confirmDialog.okButton.click();
+
+ const msg = await personalFiles.snackBar.getSnackBarMessage();
+ expect(msg).toContain('deleted');
+
+ expect(await personalFiles.snackBar.actionButton.isVisible()).toBe(false);
+
+ await personalFiles.spinnerWaitForReload();
+ expect(await personalFiles.dataTable.isItemPresent(fileLinkName)).toBe(false);
+
+ await trashPage.navigate();
+ await trashPage.spinnerWaitForReload();
+ expect(await trashPage.dataTable.isItemPresent(fileLinkName)).toBe(false);
+ });
+
+ test('[XAT-19633] Should permanently delete a linked folder without showing Undo or placing it in Trash', async ({ personalFiles, trashPage }) => {
+ await navigateToFolderById(personalFiles, destinationFolderId);
+ await Utils.reloadPageIfRowNotVisible(personalFiles, folderLinkName);
+
+ const deleteAction = await personalFiles.dataTable.getActionLocatorFromExpandableMenu(folderLinkName, 'Delete');
+ await deleteAction.click();
+ await personalFiles.confirmDialog.okButton.click();
+
+ const msg = await personalFiles.snackBar.getSnackBarMessage();
+ expect(msg).toContain('deleted');
+
+ expect(await personalFiles.snackBar.actionButton.isVisible()).toBe(false);
+
+ await personalFiles.spinnerWaitForReload();
+ expect(await personalFiles.dataTable.isItemPresent(folderLinkName)).toBe(false);
+
+ await trashPage.navigate();
+ await trashPage.spinnerWaitForReload();
+ expect(await trashPage.dataTable.isItemPresent(folderLinkName)).toBe(false);
+ });
+
+ test('[XAT-19634] Should display N/A for size in the info drawer of a linked file', async ({ personalFiles }) => {
+ await navigateToFolderById(personalFiles, destinationFolderId);
+ await Utils.reloadPageIfRowNotVisible(personalFiles, fileLinkName);
+
+ await personalFiles.dataTable.selectItems(fileLinkName);
+ await personalFiles.acaHeader.clickMoreActions();
+ await personalFiles.matMenu.clickMenuItem('Information');
+
+ await expect(personalFiles.folderInformationDialog.folderSize).toBeVisible();
+ const sizeText = await personalFiles.folderInformationDialog.folderSize.textContent();
+ expect(sizeText?.trim()).toBe('N/A');
+
+ await personalFiles.folderInformationDialog.doneButton.click();
+ });
+
+ test('[XAT-19635] Should display N/A for size in the info drawer of a linked folder', async ({ personalFiles }) => {
+ await navigateToFolderById(personalFiles, destinationFolderId);
+ await Utils.reloadPageIfRowNotVisible(personalFiles, folderLinkName);
+
+ await personalFiles.dataTable.selectItems(folderLinkName);
+ await personalFiles.acaHeader.clickMoreActions();
+ await personalFiles.matMenu.clickMenuItem('Information');
+
+ await expect(personalFiles.folderInformationDialog.folderSize).toBeVisible();
+ const sizeText = await personalFiles.folderInformationDialog.folderSize.textContent();
+ expect(sizeText?.trim()).toBe('N/A');
+
+ await personalFiles.folderInformationDialog.doneButton.click();
+ });
+
+ test('[XAT-19636] Should locate the original file when using Locate Linked Item on a file link', async ({ personalFiles }) => {
+ await navigateToFolderById(personalFiles, destinationFolderId);
+ await Utils.reloadPageIfRowNotVisible(personalFiles, fileLinkName);
+
+ await personalFiles.dataTable.performActionFromExpandableMenu(fileLinkName, 'Locate Linked Item');
+ await personalFiles.spinnerWaitForReload();
+
+ await personalFiles.page.waitForURL('**/#/personal-files**');
+ expect(await personalFiles.dataTable.isItemPresent(sourceFile)).toBe(true);
+ });
+
+ test('[XAT-19637] Should locate the original folder when using Locate Linked Item on a folder link', async ({ personalFiles }) => {
+ await navigateToFolderById(personalFiles, destinationFolderId);
+ await Utils.reloadPageIfRowNotVisible(personalFiles, folderLinkName);
+
+ await personalFiles.dataTable.performActionFromExpandableMenu(folderLinkName, 'Locate Linked Item');
+ await personalFiles.spinnerWaitForReload();
+
+ await personalFiles.page.waitForURL('**/#/personal-files**');
+ expect(await personalFiles.dataTable.isItemPresent(sourceFolder)).toBe(true);
+ });
+
+ test('[XAT-19638] Should navigate to original file location when opening a file link', async ({ personalFiles }) => {
+ await navigateToFolderById(personalFiles, destinationFolderId);
+ await Utils.reloadPageIfRowNotVisible(personalFiles, fileLinkName);
+
+ await personalFiles.dataTable.performClickFolderOrFileToOpen(fileLinkName);
+ await personalFiles.spinnerWaitForReload();
+
+ await personalFiles.page.waitForURL('**/#/personal-files**');
+ expect(await personalFiles.dataTable.isItemPresent(sourceFile)).toBe(true);
+ });
+
+ test('[XAT-19639] Should navigate inside the original folder when opening a folder link', async ({ personalFiles }) => {
+ await navigateToFolderById(personalFiles, destinationFolderId);
+ await Utils.reloadPageIfRowNotVisible(personalFiles, folderLinkName);
+
+ await personalFiles.dataTable.performClickFolderOrFileToOpen(folderLinkName);
+ await personalFiles.spinnerWaitForReload();
+
+ await personalFiles.page.waitForURL(`**/${sourceFolderId}**`);
+ await expect(personalFiles.breadcrumb.currentItem).toHaveText(sourceFolder);
+ });
+
+ const navigateToFolderById = async (page: PersonalFilesPage, nodeId: string): Promise => {
+ await page.navigate({ remoteUrl: `./#/personal-files/${nodeId}`, waitUntil: 'load' });
+ await page.spinnerWaitForReload();
+ };
+
+ const copyItemInPage = (page: PersonalFilesPage, itemName: string, destinationName: string): Promise =>
+ performNodeAction(page, itemName, 'Copy', destinationName);
+
+ const moveItemInPage = (page: PersonalFilesPage, itemName: string, destinationName: string): Promise =>
+ performNodeAction(page, itemName, 'Move', destinationName);
+
+ const performNodeAction = async (page: PersonalFilesPage, itemName: string, action: 'Copy' | 'Move', destinationName: string): Promise => {
+ await page.dataTable.selectItems(itemName);
+ await page.clickMoreActionsButton(action);
+ await page.contentNodeSelector.searchAndSelectDestination(destinationName);
+ await page.contentNodeSelector.actionButton.click();
+ await page.spinnerWaitForReload();
+ };
+});
diff --git a/e2e/playwright/create-link-actions/src/tests/link-permissions.e2e.ts b/e2e/playwright/create-link-actions/src/tests/link-permissions.e2e.ts
new file mode 100644
index 000000000..8405eabfd
--- /dev/null
+++ b/e2e/playwright/create-link-actions/src/tests/link-permissions.e2e.ts
@@ -0,0 +1,281 @@
+/*!
+ * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
+ *
+ * Alfresco Example Content Application
+ *
+ * This file is part of the Alfresco Example Content Application.
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ *
+ * The Alfresco Example Content Application is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Alfresco Example Content Application is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * from Hyland Software. If not, see .
+ */
+
+import { ApiClientFactory, NodesApi, SitesApi, TrashcanApi, Utils, test, LoginPage, PersonalFilesPage } from '@alfresco/aca-playwright-shared';
+import { Site } from '@alfresco/js-api';
+import { expect } from '@playwright/test';
+
+test.describe('Create Link - comments and permissions', () => {
+ let ownerNodesApi: NodesApi;
+ let consumerNodesApi: NodesApi;
+ let ownerTrashcanApi: TrashcanApi;
+ let consumerTrashcanApi: TrashcanApi;
+ let sitesApi: SitesApi;
+
+ const random = Utils.random();
+ const ownerUsername = `owner-${random}`;
+ const consumerUsername = `consumer-${random}`;
+
+ let siteId: string;
+ let siteTitle: string;
+ let docLibId: string;
+ let sourceFile: string;
+ let sourceFolder: string;
+ let destinationFolder: string;
+
+ let sourceFileId: string;
+ let destinationFolderId: string;
+
+ let fileLinkName: string;
+ let folderLinkName: string;
+
+ test.beforeAll(async () => {
+ try {
+ const apiClientFactory = new ApiClientFactory();
+ await apiClientFactory.setUpAcaBackend('admin');
+ await apiClientFactory.createUser({ username: ownerUsername });
+ await apiClientFactory.createUser({ username: consumerUsername });
+
+ ownerNodesApi = await NodesApi.initialize(ownerUsername, ownerUsername);
+ consumerNodesApi = await NodesApi.initialize(consumerUsername, consumerUsername);
+ ownerTrashcanApi = await TrashcanApi.initialize(ownerUsername, ownerUsername);
+ consumerTrashcanApi = await TrashcanApi.initialize(consumerUsername, consumerUsername);
+ sitesApi = await SitesApi.initialize(ownerUsername, ownerUsername);
+
+ const site = await sitesApi.createSite(`link-perm-site-${random}`, Site.VisibilityEnum.PRIVATE);
+ siteId = site.entry.id;
+ siteTitle = site.entry.title;
+ docLibId = await sitesApi.getDocLibId(siteId);
+ await sitesApi.addSiteMember(siteId, consumerUsername, 'SiteConsumer');
+ } catch (error) {
+ console.error(`beforeAll failed: ${error}`);
+ throw error;
+ }
+ });
+
+ test.beforeEach(async ({ personalFiles, page }) => {
+ try {
+ sourceFile = `source-file-${Utils.random()}.txt`;
+ sourceFolder = `source-folder-${Utils.random()}`;
+ destinationFolder = `destination-folder-${Utils.random()}`;
+
+ const loginPage = new LoginPage(page);
+ await Utils.tryLoginUser(loginPage, ownerUsername, ownerUsername, 'beforeEach failed');
+
+ sourceFileId = (await ownerNodesApi.createFile(sourceFile)).entry.id;
+ const sourceFolderEntry = await ownerNodesApi.createFolder(sourceFolder);
+ destinationFolderId = (await ownerNodesApi.createFolder(destinationFolder)).entry.id;
+
+ const fileLinkEntry = await ownerNodesApi.createFileLink(sourceFileId, destinationFolderId);
+ const folderLinkEntry = await ownerNodesApi.createFolderLink(sourceFolderEntry.entry.id, destinationFolderId);
+
+ fileLinkName = fileLinkEntry.entry.name;
+ folderLinkName = folderLinkEntry.entry.name;
+
+ await personalFiles.navigate();
+ } catch (error) {
+ console.error(`beforeEach failed: ${error}`);
+ throw error;
+ }
+ });
+
+ test.afterAll(async () => {
+ let primaryError: unknown;
+
+ try {
+ await Utils.deleteNodesSitesEmptyTrashcan(consumerNodesApi, consumerTrashcanApi, 'afterAll failed (consumer cleanup)');
+ } catch (error) {
+ console.error(`afterAll failed (consumer cleanup): ${error}`);
+ primaryError = error;
+ }
+
+ try {
+ await Utils.deleteNodesSitesEmptyTrashcan(ownerNodesApi, ownerTrashcanApi, 'afterAll failed', sitesApi, [siteId]);
+ } catch (error) {
+ console.error(`afterAll failed: ${error}`);
+ primaryError ??= error;
+ }
+
+ if (primaryError) {
+ throw primaryError;
+ }
+ });
+
+ test('[XAT-19640] Should show a comment added on a linked file in the original file info drawer', async ({ personalFiles }) => {
+ const commentText = `comment-${Utils.random()}`;
+
+ await personalFiles.navigate({ remoteUrl: `./#/personal-files/${destinationFolderId}`, waitUntil: 'load' });
+ await personalFiles.spinnerWaitForReload();
+ await Utils.reloadPageIfRowNotVisible(personalFiles, fileLinkName);
+
+ await personalFiles.dataTable.getRowByName(fileLinkName).click();
+ await personalFiles.acaHeader.viewDetails.click();
+ await personalFiles.infoDrawer.commentsTab.click();
+ await expect(personalFiles.infoDrawer.commentInputField).toBeVisible();
+ await personalFiles.infoDrawer.addCommentToNode(commentText);
+
+ await personalFiles.infoDrawer.waitForComments();
+
+ await personalFiles.navigate({ remoteUrl: `./#/personal-files`, waitUntil: 'load' });
+ await personalFiles.spinnerWaitForReload();
+ await Utils.reloadPageIfRowNotVisible(personalFiles, sourceFile);
+
+ await personalFiles.dataTable.getRowByName(sourceFile).click();
+ await personalFiles.acaHeader.viewDetails.click();
+ await personalFiles.infoDrawer.commentsTab.click();
+ await personalFiles.infoDrawer.waitForComments();
+
+ const commentsCount = await personalFiles.infoDrawer.getCommentsCountFromList();
+ expect(commentsCount).toBeGreaterThanOrEqual(1);
+
+ const commentContent = await personalFiles.infoDrawer.commentTextContent.first().innerText();
+ expect(commentContent).toContain(commentText);
+ });
+
+ test('[XAT-19641] Should show a comment added on a linked folder in the original folder info drawer', async ({ personalFiles }) => {
+ const commentText = `comment-${Utils.random()}`;
+
+ await personalFiles.navigate({ remoteUrl: `./#/personal-files/${destinationFolderId}`, waitUntil: 'load' });
+ await personalFiles.spinnerWaitForReload();
+ await Utils.reloadPageIfRowNotVisible(personalFiles, folderLinkName);
+
+ await personalFiles.dataTable.getRowByName(folderLinkName).click();
+ await personalFiles.acaHeader.viewDetails.click();
+ await personalFiles.infoDrawer.commentsTab.click();
+ await expect(personalFiles.infoDrawer.commentInputField).toBeVisible();
+ await personalFiles.infoDrawer.addCommentToNode(commentText);
+
+ await personalFiles.infoDrawer.waitForComments();
+
+ await personalFiles.navigate({ remoteUrl: `./#/personal-files`, waitUntil: 'load' });
+ await personalFiles.spinnerWaitForReload();
+ await Utils.reloadPageIfRowNotVisible(personalFiles, sourceFolder);
+
+ await personalFiles.dataTable.getRowByName(sourceFolder).click();
+ await personalFiles.acaHeader.viewDetails.click();
+ await personalFiles.infoDrawer.commentsTab.click();
+ await personalFiles.infoDrawer.waitForComments();
+
+ const commentsCount = await personalFiles.infoDrawer.getCommentsCountFromList();
+ expect(commentsCount).toBeGreaterThanOrEqual(1);
+
+ const commentContent = await personalFiles.infoDrawer.commentTextContent.first().innerText();
+ expect(commentContent).toContain(commentText);
+ });
+
+ test('[XAT-19642] Should show Create Link action for manager and successfully create a link from a site document library', async ({
+ personalFiles
+ }) => {
+ const siteFile = `site-file-${Utils.random()}.txt`;
+ const managerDestFolder = `manager-dest-${Utils.random()}`;
+
+ await ownerNodesApi.createFile(siteFile, docLibId);
+ const managerDestFolderEntry = await ownerNodesApi.createFolder(managerDestFolder);
+ const managerDestFolderId = managerDestFolderEntry.entry.id;
+
+ await personalFiles.navigate({ remoteUrl: `./#/libraries/${docLibId}`, waitUntil: 'load' });
+ await personalFiles.spinnerWaitForReload();
+ await Utils.reloadPageIfRowNotVisible(personalFiles, siteFile);
+
+ await personalFiles.dataTable.rightClickOnItem(siteFile);
+ const createLinkButton = personalFiles.dataTable.contextMenuActions.getButtonByText('Create Link');
+ await expect(createLinkButton).toBeVisible();
+ await createLinkButton.click();
+
+ await personalFiles.contentNodeSelector.searchAndSelectDestination(managerDestFolder);
+ await personalFiles.contentNodeSelector.actionButton.click();
+
+ const msg = await personalFiles.snackBar.getSnackBarMessage();
+ expect(msg).toContain('Link created for 1 item');
+
+ await personalFiles.navigate({ remoteUrl: `./#/personal-files/${managerDestFolderId}`, waitUntil: 'load' });
+ await personalFiles.spinnerWaitForReload();
+
+ const expectedLinkName = `Link to ${siteFile}.url`;
+ expect(await personalFiles.dataTable.isItemPresent(expectedLinkName)).toBe(true);
+ });
+
+ test('[XAT-19643] Should allow a consumer to create a link to a site file in their own personal files', async ({ personalFiles, page }) => {
+ const siteFile = `site-file-consumer-${Utils.random()}.txt`;
+ const consumerDestFolder = `consumer-dest-${Utils.random()}`;
+
+ await ownerNodesApi.createFile(siteFile, docLibId);
+ const consumerDestFolderEntry = await consumerNodesApi.createFolder(consumerDestFolder);
+ const consumerDestFolderId = consumerDestFolderEntry.entry.id;
+
+ const loginPage = new LoginPage(page);
+ await loginPage.logoutUser();
+ await loginPage.loginUser({ username: consumerUsername, password: consumerUsername }, { withNavigation: true, waitForLoading: true });
+
+ await personalFiles.navigate({ remoteUrl: `./#/libraries/${docLibId}`, waitUntil: 'load' });
+ await personalFiles.spinnerWaitForReload();
+ await Utils.reloadPageIfRowNotVisible(personalFiles, siteFile);
+
+ await personalFiles.dataTable.rightClickOnItem(siteFile);
+ const createLinkButton = personalFiles.dataTable.contextMenuActions.getButtonByText('Create Link');
+ await expect(createLinkButton).toBeVisible();
+ await createLinkButton.click();
+
+ await personalFiles.contentNodeSelector.searchAndSelectDestination(consumerDestFolder);
+ await personalFiles.contentNodeSelector.actionButton.click();
+
+ const msg = await personalFiles.snackBar.getSnackBarMessage();
+ expect(msg).toContain('Link created for 1 item');
+
+ await personalFiles.navigate({ remoteUrl: `./#/personal-files/${consumerDestFolderId}`, waitUntil: 'load' });
+ await personalFiles.spinnerWaitForReload();
+
+ const expectedLinkName = `Link to ${siteFile}.url`;
+ expect(await personalFiles.dataTable.isItemPresent(expectedLinkName)).toBe(true);
+ });
+
+ test('[XAT-19644] Should disable the Choose button in the node selector when consumer selects a read-only library folder', async ({ page }) => {
+ const siteFile = `site-file-ro-${Utils.random()}.txt`;
+ await ownerNodesApi.createFile(siteFile, docLibId);
+
+ const loginPage = new LoginPage(page);
+ await loginPage.logoutUser();
+ await loginPage.loginUser({ username: consumerUsername, password: consumerUsername }, { withNavigation: true, waitForLoading: true });
+
+ const consumerPage = new PersonalFilesPage(page);
+
+ await consumerPage.navigate({ remoteUrl: `./#/libraries/${docLibId}`, waitUntil: 'load' });
+ await consumerPage.spinnerWaitForReload();
+ await Utils.reloadPageIfRowNotVisible(consumerPage, siteFile);
+
+ await consumerPage.dataTable.selectItems(siteFile);
+ await consumerPage.acaHeader.clickMoreActions();
+ const createLinkMenuItem = consumerPage.matMenu.getButtonByText('Create Link');
+ await expect(createLinkMenuItem).toBeVisible();
+ await createLinkMenuItem.click();
+
+ await consumerPage.contentNodeSelector.selectLocation('My Libraries');
+ await consumerPage.contentNodeSelector.spinnerWaitForReload();
+
+ await consumerPage.contentNodeSelector.selectDestination(siteTitle);
+ expect(await consumerPage.contentNodeSelector.actionButton.isDisabled()).toBe(true);
+
+ await consumerPage.contentNodeSelector.cancelButton.click();
+ });
+});
diff --git a/e2e/playwright/create-link-actions/tsconfig.e2e.adf.json b/e2e/playwright/create-link-actions/tsconfig.e2e.adf.json
new file mode 100644
index 000000000..47ea883b4
--- /dev/null
+++ b/e2e/playwright/create-link-actions/tsconfig.e2e.adf.json
@@ -0,0 +1,15 @@
+{
+ "extends": "../../../tsconfig.adf.json",
+ "compilerOptions": {
+ "outDir": "../../out-tsc/e2e",
+ "baseUrl": "./",
+ "module": "commonjs",
+ "target": "es2017",
+ "types": ["jasmine", "jasminewd2", "node"],
+ "skipLibCheck": true,
+ "paths": {
+ "@alfresco/aca-playwright-shared": ["dist/@alfresco/aca-playwright-shared"]
+ }
+ },
+ "exclude": ["node_modules"]
+}
diff --git a/e2e/playwright/create-link-actions/tsconfig.e2e.json b/e2e/playwright/create-link-actions/tsconfig.e2e.json
new file mode 100644
index 000000000..16d87d8ec
--- /dev/null
+++ b/e2e/playwright/create-link-actions/tsconfig.e2e.json
@@ -0,0 +1,15 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../out-tsc/e2e",
+ "baseUrl": "./",
+ "module": "commonjs",
+ "target": "es2017",
+ "types": ["jasmine", "jasminewd2", "node", "@playwright/test"],
+ "skipLibCheck": true,
+ "paths": {
+ "@alfresco/aca-playwright-shared": ["dist/@alfresco/aca-playwright-shared"]
+ }
+ },
+ "exclude": ["node_modules"]
+}