diff --git a/.woodpecker.star b/.woodpecker.star index cd5c211dfa..67577458d2 100644 --- a/.woodpecker.star +++ b/.woodpecker.star @@ -183,6 +183,15 @@ config = { "mobile-view", ], }, + "localization-de": { + "skip": False, + "features": [ + "cucumber/features/a11y/smoke.feature", + ], + "extraServerEnvironment": { + "OC_DEFAULT_LANGUAGE": "de", + }, + }, }, "build": True, } @@ -555,6 +564,9 @@ def e2eTests(ctx): if "ocm" in suite and not "full-ci" in ctx.build.title.lower() and ctx.build.event != "cron": continue + if "localization-de" in suite and "localization-de" not in ctx.build.title.lower(): + continue + if params["skip"]: continue @@ -630,6 +642,9 @@ def e2eTests(ctx): else: pipeline_name = "e2e-tests-%s-%s" % (suite, browser_name) + if "localization-de" in suite: + command = "RUN_LOCALIZATION_TEST_FOR_LANG=de pnpm test:e2e:cucumber tests/e2e/cucumber/features/a11y/smoke.feature" + steps += [{ "name": "e2e-tests", "image": OC_CI_NODEJS, diff --git a/package.json b/package.json index 3d6850c847..1d0f456fd6 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "commander": "14.0.1", "ejs": "3.1.10", "eslint": "9.37.0", + "franc-min": "^6.2.0", "glob": "11.0.3", "happy-dom": "19.0.2", "jsdom": "^27.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59ebd0177d..3c2f8c0ab6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,9 @@ importers: eslint: specifier: 9.37.0 version: 9.37.0(jiti@2.6.1) + franc-min: + specifier: ^6.2.0 + version: 6.2.0 glob: specifier: 11.0.3 version: 11.0.3 @@ -3682,6 +3685,9 @@ packages: codemirror@6.0.2: resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -4326,6 +4332,9 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + franc-min@6.2.0: + resolution: {integrity: sha512-1uDIEUSlUZgvJa2AKYR/dmJC66v/PvGQ9mWfI9nOr/kPpMFyvswK0gPXOwpYJYiYD008PpHLkGfG58SPjQJFxw==} + fs-extra@11.3.2: resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} engines: {node: '>=14.14'} @@ -5178,6 +5187,9 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + n-gram@2.0.2: + resolution: {integrity: sha512-S24aGsn+HLBxUGVAUFOwGpKs7LBcG4RudKU//eWzt/mQ97/NMKQxDWHyHx63UNWk/OOdihgmzoETn1tf5nQDzQ==} + namespace-emitter@2.0.1: resolution: {integrity: sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==} @@ -6126,6 +6138,9 @@ packages: resolution: {integrity: sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==} engines: {node: '>=0.6'} + trigram-utils@2.0.1: + resolution: {integrity: sha512-nfWIXHEaB+HdyslAfMxSqWKDdmqY9I32jS7GnqpdWQnLH89r6A5sdk3fDVYqGAZ0CrT8ovAFSAo6HRiWcWNIGQ==} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -9713,6 +9728,8 @@ snapshots: '@codemirror/state': 6.5.2 '@codemirror/view': 6.38.2 + collapse-white-space@2.1.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -10443,6 +10460,10 @@ snapshots: dependencies: fetch-blob: 3.2.0 + franc-min@6.2.0: + dependencies: + trigram-utils: 2.0.1 + fs-extra@11.3.2: dependencies: graceful-fs: 4.2.11 @@ -11301,6 +11322,8 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + n-gram@2.0.2: {} + namespace-emitter@2.0.1: {} nanoid@3.3.11: {} @@ -12282,6 +12305,11 @@ snapshots: treeify@1.1.0: {} + trigram-utils@2.0.1: + dependencies: + collapse-white-space: 2.1.0 + n-gram: 2.0.2 + trim-lines@3.0.1: {} ts-api-utils@2.1.0(typescript@5.9.3): diff --git a/tests/e2e/cucumber/features/a11y/smoke.feature b/tests/e2e/cucumber/features/a11y/smoke.feature index 186a3fce67..96a0fca15e 100644 --- a/tests/e2e/cucumber/features/a11y/smoke.feature +++ b/tests/e2e/cucumber/features/a11y/smoke.feature @@ -49,10 +49,11 @@ Feature: Accessibility checks # personal space And "Alice" opens the "files" app And "Alice" checks the accessibility of the DOM selector ".files-view-wrapper" on the "personal space" - And "Alice" switches to the "tiles" view - And "Alice" checks the accessibility of the DOM selector ".files-view-wrapper" on the "personal space" And "Alice" switches to the "table-condensed" view And "Alice" checks the accessibility of the DOM selector ".files-view-wrapper" on the "personal space" + # check empty page + And "Brian" opens the "files" app + And "Brian" checks the accessibility of the DOM selector ".files-view-wrapper" on the "personal space" # shares And "Alice" navigates to the shared with me page @@ -150,8 +151,11 @@ Feature: Accessibility checks ## 6. space page And "Alice" navigates to the project space "my_space" And "Alice" checks the accessibility of the DOM selector "#files-view" on the "project space page" - - + And "Brian" opens the "files" app + And "Brian" navigates to the projects space page + And "Brian" checks the accessibility of the DOM selector "#files-view" on the "project spaces page" + + ## 7. app-sidebar (right sidebar) And "Alice" opens the "files" app And "Alice" opens the right sidebar of the resource "lorem.txt" @@ -161,19 +165,20 @@ Feature: Accessibility checks And "Alice" opens a "versions" panel of the resource "lorem.txt" And "Alice" checks the accessibility of the DOM selector "#sidebar-panel-versions" on the "right sidebar->versions panel" And "Alice" opens a "activities" panel of the resource "lorem.txt" - And "Alice" checks the accessibility of the DOM selector "#sidebar-panel-sharing" on the "right sidebar->activities panel" + And "Alice" checks the accessibility of the DOM selector "#sidebar-panel-activities" on the "right sidebar->activities panel" And "Alice" opens a "sharing" panel of the resource "lorem.txt" And "Alice" checks the accessibility of the DOM selector "#sidebar-panel-sharing" on the "right sidebar->sharing panel" # check create public link modal and link role dropdown And "Alice" creates a public link of following resource using the sidebar panel - | resource | role | password | - | parent | Secret File Drop | %public% | + | resource | role | password | + | parent | Secret File Drop | %public% | ## 8. public link page And "Anonymous" opens the public link "Unnamed link" + And "Anonymous" checks the accessibility of the DOM selector "#opencloud" on the "public link page->before unlock" And "Anonymous" unlocks the public link with password "%public%" - And "Anonymous" checks the accessibility of the DOM selector "#web-content" on the "public link page" + And "Anonymous" checks the accessibility of the DOM selector "#files" on the "public link page" And "Alice" logs out And "Brian" logs out diff --git a/tests/e2e/cucumber/steps/ui/a11.y.ts b/tests/e2e/cucumber/steps/ui/a11.y.ts index f102160fff..951c3e8e92 100644 --- a/tests/e2e/cucumber/steps/ui/a11.y.ts +++ b/tests/e2e/cucumber/steps/ui/a11.y.ts @@ -1,11 +1,11 @@ import { Then } from '@cucumber/cucumber' import { World } from '../../environment' -import { checkAccessibility } from '../../../support/utils/accessibility' +import { checkA11yOrLocalization } from '../../../support/utils/accessibility' Then( '{string} checks the accessibility of the DOM selector {string} on the {string}', async function (this: World, stepUser: string, selector: string, context: string): Promise { const { page } = this.actorsEnvironment.getActor({ key: stepUser }) - await checkAccessibility(page, context, selector) + await checkA11yOrLocalization(page, context, selector) } ) diff --git a/tests/e2e/support/objects/app-files/link/actions.ts b/tests/e2e/support/objects/app-files/link/actions.ts index a25af35546..330fc554cf 100644 --- a/tests/e2e/support/objects/app-files/link/actions.ts +++ b/tests/e2e/support/objects/app-files/link/actions.ts @@ -4,7 +4,7 @@ import { sidebar } from '../utils' import { getActualExpiryDate } from '../../../utils/datePicker' import { clickResource } from '../resource/actions' import { config } from '../../../../config' -import { checkAccessibility } from '../../../utils/accessibility' +import { checkA11yOrLocalization } from '../../../utils/accessibility' export interface createLinkArgs { page: Page @@ -115,6 +115,12 @@ const getRecentLinkName = async (page: Page): Promise => { return await page.locator(publicLinkNameList).first().textContent() } +const roleIdMap: Record = { + 'Can view': '#files-role-view', + 'Can edit': '#files-role-edit', + 'Secret File Drop': '#files-role-createOnly' +} + export const createLink = async (args: createLinkArgs): Promise => { const { space, page, resource, password, role, a11yEnabled } = args if (!space) { @@ -129,15 +135,18 @@ export const createLink = async (args: createLinkArgs): Promise => { await page.locator(addPublicLinkButton).click() await page.locator(advancedModeButton).click() if (a11yEnabled) { - await checkAccessibility(page, 'create public link modal', createLinkModal) + await checkA11yOrLocalization(page, 'create public link modal', createLinkModal) } if (role) { + const roleSelector = roleIdMap[role] + if (!roleSelector) throw new Error(`Unknown role: ${role}`) await page.locator(publicLinkRoleToggle).click() + if (a11yEnabled) { - await checkAccessibility(page, 'check link role dropdown', linkRoleDropdown) + await checkA11yOrLocalization(page, 'check link role dropdown', linkRoleDropdown) } - await page.locator(util.format(publicLinkSetRoleButton, role)).click() + await page.locator(roleSelector).click() } await page.locator(editPublicLinkPasswordInput).fill(password) @@ -157,7 +166,9 @@ export const createLink = async (args: createLinkArgs): Promise => { if (config.browser === 'webkit') { return (await resp[0].json()).link.webUrl } else { - return await getRecentLinkUrl(page, 'Unnamed link') + const name = + process.env.RUN_LOCALIZATION_TEST_FOR_LANG === 'de' ? 'Unbenannter Link' : 'Unnamed link' + return await getRecentLinkUrl(page, name) } } diff --git a/tests/e2e/support/objects/app-files/page/shares/viaLink.ts b/tests/e2e/support/objects/app-files/page/shares/viaLink.ts index 01f6931267..2ec9795ae7 100644 --- a/tests/e2e/support/objects/app-files/page/shares/viaLink.ts +++ b/tests/e2e/support/objects/app-files/page/shares/viaLink.ts @@ -1,6 +1,7 @@ import { Page } from '@playwright/test' const sharesNavSelector = '//a[@data-nav-name="files-shares"]' +const sharesViaLinkTabSelector = 'a[href="/files/shares/via-link"]' export class ViaLink { #page: Page @@ -11,6 +12,6 @@ export class ViaLink { async navigate(): Promise { await this.#page.locator(sharesNavSelector).click() - await this.#page.getByText('Shared via link').click() + await this.#page.locator(sharesViaLinkTabSelector).click() } } diff --git a/tests/e2e/support/objects/app-files/page/shares/withOthers.ts b/tests/e2e/support/objects/app-files/page/shares/withOthers.ts index 3695725e2a..22230ede88 100644 --- a/tests/e2e/support/objects/app-files/page/shares/withOthers.ts +++ b/tests/e2e/support/objects/app-files/page/shares/withOthers.ts @@ -1,6 +1,7 @@ import { Page } from '@playwright/test' const sharesNavSelector = '//a[@data-nav-name="files-shares"]' +const sharesWithOthersTabSelector = 'a[href="/files/shares/with-others"]' export class WithOthers { #page: Page @@ -11,6 +12,6 @@ export class WithOthers { async navigate(): Promise { await this.#page.locator(sharesNavSelector).click() - await this.#page.getByText('Shared with others').click() + await this.#page.locator(sharesWithOthersTabSelector).click() } } diff --git a/tests/e2e/support/objects/app-files/page/spaces/projects.ts b/tests/e2e/support/objects/app-files/page/spaces/projects.ts index 922d3cc56c..79e870f049 100644 --- a/tests/e2e/support/objects/app-files/page/spaces/projects.ts +++ b/tests/e2e/support/objects/app-files/page/spaces/projects.ts @@ -8,7 +8,7 @@ export class Projects { } async navigate(): Promise { - await this.#page.locator('//a[@data-nav-name="files-spaces-projects"]').click() + await this.#page.locator('a[data-nav-name="files-spaces-projects"]').click() await this.#page.locator('#app-loading-spinner').waitFor({ state: 'detached' }) } } diff --git a/tests/e2e/support/objects/app-files/trashbin/actions.ts b/tests/e2e/support/objects/app-files/trashbin/actions.ts index 531b3cdc90..faf6df9ad6 100644 --- a/tests/e2e/support/objects/app-files/trashbin/actions.ts +++ b/tests/e2e/support/objects/app-files/trashbin/actions.ts @@ -8,6 +8,7 @@ const emptyTrashbinQuickActionBtn = '//*[@data-test-resource-name="%s"]//ancestor::tr//button[@aria-label="Empty trash bin"] | //*[@data-test-resource-name="%s"]//ancestor::li[contains(@class, "oc-tiles-item")]//button[@aria-label="Empty trash bin"]' const actionConfirmButton = '.oc-modal-body-actions-confirm' const footerTextSelector = '//*[@data-testid="files-list-footer-info"]' +const personalTrashbinSelector = 'a[href^="/files/trash/personal/"]' export interface openTrashBinArgs { id: string @@ -20,7 +21,7 @@ export const openTrashbinOfProjectSpace = async (args: openTrashBinArgs): Promis } export const openTrashbinOfPersonalSpace = async (page: Page): Promise => { - await page.getByTitle('Personal').click() + await page.locator(personalTrashbinSelector).first().click() } export const showEmptyTrashbins = async (page: Page): Promise => { diff --git a/tests/e2e/support/objects/runtime/session.ts b/tests/e2e/support/objects/runtime/session.ts index 1f50a0d1bd..63f507cc4e 100644 --- a/tests/e2e/support/objects/runtime/session.ts +++ b/tests/e2e/support/objects/runtime/session.ts @@ -1,7 +1,7 @@ import { Page } from '@playwright/test' import { User } from '../../types' import { config } from '../../../config' -import { checkAccessibility } from '../../utils/accessibility' +import { checkA11yOrLocalization } from '../../utils/accessibility' export class Session { #page: Page @@ -21,7 +21,7 @@ export class Session { await this.#page.locator('#oc-login-username').fill(username) await this.#page.locator('#oc-login-password').fill(password) if (a11y) { - await checkAccessibility(this.#page, 'before clicking login submit') + await checkA11yOrLocalization(this.#page, 'before clicking login submit', '#root') } await this.#page.locator('button[type="submit"]').click() } diff --git a/tests/e2e/support/utils/accessibility.ts b/tests/e2e/support/utils/accessibility.ts index e320e0889d..b65231f8a4 100644 --- a/tests/e2e/support/utils/accessibility.ts +++ b/tests/e2e/support/utils/accessibility.ts @@ -1,17 +1,28 @@ -import { Page } from '@playwright/test' +import { Page, expect } from '@playwright/test' import AxeBuilder from '@axe-core/playwright' +import { franc } from 'franc-min' const a11yRuleTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'] -export async function checkAccessibility( +export async function checkA11yOrLocalization( page: Page, - context: string = '', - includeSelector?: string + context: string, + selector: string ): Promise { - let builder = new AxeBuilder({ page }).withTags(a11yRuleTags) - if (includeSelector) { - builder = builder.include(includeSelector) + if (process.env.RUN_LOCALIZATION_TEST_FOR_LANG === 'de') { + await checkGermanLanguage(page, selector, context) + } else { + await checkAccessibility(page, context, selector) } +} + +async function checkAccessibility( + page: Page, + context: string = '', + includeSelector: string +): Promise { + await expect(page.locator(includeSelector)).toBeVisible() + const builder = new AxeBuilder({ page }).withTags(a11yRuleTags).include(includeSelector) const results = await builder.analyze() if (results.violations.length > 0) { @@ -28,3 +39,101 @@ export async function checkAccessibility( throw new Error(`Accessibility check failed${context ? ` in ${context}` : ''}.`) } } + +async function checkGermanLanguage( + page: Page, + includeSelector: string, + context: string +): Promise { + await expect(page.locator(includeSelector)).toBeVisible() + + // wait a bit for dynamic content to load + await page.waitForTimeout(1000) + + const texts: string[] = await page.evaluate((includeSelector: string) => { + const allowList = [ + 'OpenCloud', + 'web', + 'Spaces', + 'space', + 'parent', + 'lorem', + 'alice', + 'brian', + 'admin', + 'testavatar', + 'A-Z', + 'my_space', + 'GB', + 'MB', + 'KB', + 'B', + 'TB', + 'Gb', + 'Mb', + 'Kb', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '0', + 'Ctrl', + '⌘', + 'Name', + 'Status', + 'E-Mail', + 'Neu', + 'Aktionen', + 'Tags', + 'Personen', + 'CalDAV', + 'CalCAV', + 'Typ', + 'Link', + '...', + 'Version', + 'Passwort', + 'Avatar', + 'Manager', + 'Titel' + ] + + const shouldIgnore = (text: string) => { + if (text.length < 3) return true + if (/\.(txt|jpeg|md|.odt|png|pdf|docx)$/i.test(text)) return true + if (allowList.some((a) => text.toLowerCase().includes(a.toLowerCase()))) return true + return /function|var |const |let |=>|return/.test(text) + } + + const root = document.querySelector(includeSelector) + if (!root) return [] + const arr: string[] = [] + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null) + while (walker.nextNode()) { + const text = walker.currentNode.nodeValue?.trim() + if (text && text.length > 2 && !shouldIgnore(text)) { + arr.push(text) + } + } + return arr + }, includeSelector) + + const nonGerman: string[] = [] + for (const t of texts) { + const lang = franc(t, { minLength: 3, only: ['deu', 'eng'] }) + if (lang !== 'deu') { + nonGerman.push(`${t} [${lang}]`) + } + } + + if (nonGerman.length > 0) { + console.log(`🌐 Language check failed${context ? ` in ${context}` : ''}. Non-German texts:`) + nonGerman.forEach((text) => console.log(` - ${text}`)) + } +}