Skip to content

Commit 9c75104

Browse files
authored
Add localization e2e tests (#2052)
* add localization e2e tests * Address PR review comments: fix timeouts, error handling, and add deletion verification * Fix e2e test: increase timeout for site deletion verification to 30s * Make site name input selector backward compatible with trunk for performance tests * Make site status button selector backward compatible with trunk for performance tests * fix lint * Fix e2e test: handle platform differences in WordPress URL parameters * Fix e2e test: handle multi-level URL encoding on Windows * fix lint
1 parent 15b46a7 commit 9c75104

18 files changed

+348
-15
lines changed

e2e/e2e-helpers.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,30 @@ export class E2ESession {
5151
this.mainWindow = await this.electronApp.firstWindow( { timeout: 60_000 } );
5252
}
5353

54+
// Close the app but keep the data for persistence testing
55+
async restart() {
56+
await this.electronApp?.close();
57+
const latestBuild = findLatestBuild();
58+
const appInfo = parseElectronApp( latestBuild );
59+
let executablePath = appInfo.executable;
60+
if ( appInfo.platform === 'win32' ) {
61+
executablePath = executablePath.replace( 'Squirrel.exe', 'Studio.exe' );
62+
}
63+
64+
this.electronApp = await electron.launch( {
65+
args: [ appInfo.main ],
66+
executablePath,
67+
env: {
68+
...process.env,
69+
E2E: 'true',
70+
E2E_APP_DATA_PATH: this.appDataPath,
71+
E2E_HOME_PATH: this.homePath,
72+
},
73+
timeout: 60_000,
74+
} );
75+
this.mainWindow = await this.electronApp.firstWindow( { timeout: 60_000 } );
76+
}
77+
5478
async cleanup() {
5579
await this.electronApp?.close();
5680
// Clean up temporary folder to hold application data

e2e/localization.test.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { test, expect } from '@playwright/test';
2+
import { E2ESession } from './e2e-helpers';
3+
import AddSiteModal from './page-objects/add-site-modal';
4+
import MainSidebar from './page-objects/main-sidebar';
5+
import Onboarding from './page-objects/onboarding';
6+
import SiteContent from './page-objects/site-content';
7+
import WhatsNewModal from './page-objects/whats-new-modal';
8+
import { getUrlWithAutoLogin } from './utils';
9+
10+
test.describe( 'Localization', () => {
11+
const session = new E2ESession();
12+
13+
// Helper function to open settings using data-testid
14+
const openSettings = async ( page: typeof session.mainWindow ) => {
15+
const settingsButton = page.getByTestId( 'settings-button' );
16+
await expect( settingsButton ).toBeVisible();
17+
await settingsButton.click();
18+
};
19+
20+
// Helper to change language
21+
const changeLanguage = async ( page: typeof session.mainWindow, localeCode: string ) => {
22+
await openSettings( page );
23+
24+
// Wait for Settings modal
25+
const settingsModal = page.getByRole( 'dialog' );
26+
await expect( settingsModal ).toBeVisible( { timeout: 10_000 } );
27+
28+
// Select language using data-testid
29+
const languageSelect = page.getByTestId( 'language-select' );
30+
await expect( languageSelect ).toBeVisible( { timeout: 10_000 } );
31+
await languageSelect.selectOption( localeCode );
32+
33+
// Click Save button using data-testid
34+
const saveButton = page.getByTestId( 'preferences-save-button' );
35+
await expect( saveButton ).toBeVisible();
36+
await expect( saveButton ).toBeEnabled(); // Wait for it to be enabled
37+
await saveButton.click();
38+
39+
// Wait for modal to close
40+
await expect( settingsModal ).not.toBeVisible( { timeout: 5_000 } );
41+
};
42+
43+
test.beforeAll( async () => {
44+
await session.launch();
45+
46+
// Complete onboarding before tests
47+
const onboarding = new Onboarding( session.mainWindow );
48+
await expect( onboarding.heading ).toBeVisible();
49+
await onboarding.continueButton.click();
50+
51+
const whatsNewModal = new WhatsNewModal( session.mainWindow );
52+
if ( await whatsNewModal.locator.isVisible( { timeout: 5000 } ) ) {
53+
await whatsNewModal.closeButton.click();
54+
}
55+
56+
const siteContent = new SiteContent( session.mainWindow, 'My WordPress Website' );
57+
await expect( siteContent.siteNameHeading ).toBeVisible( { timeout: 120_000 } );
58+
} );
59+
60+
test.afterAll( async () => {
61+
await session.cleanup();
62+
} );
63+
64+
test( 'changes language from settings', async () => {
65+
const sidebar = new MainSidebar( session.mainWindow );
66+
67+
await changeLanguage( session.mainWindow, 'fr' );
68+
await expect( sidebar.addSiteButton ).toHaveText( 'Ajouter un site' );
69+
await changeLanguage( session.mainWindow, 'en' );
70+
await expect( sidebar.addSiteButton ).toHaveText( 'Add site' );
71+
} );
72+
73+
test( 'supports RTL languages', async () => {
74+
const sidebar = new MainSidebar( session.mainWindow );
75+
76+
await changeLanguage( session.mainWindow, 'ar' );
77+
const htmlDir = await session.mainWindow.evaluate( () => document.documentElement.dir );
78+
expect( htmlDir ).toBe( 'rtl' );
79+
await expect( sidebar.addSiteButton ).toHaveText( 'إضافة موقع' );
80+
await changeLanguage( session.mainWindow, 'en' );
81+
82+
const htmlDirEnglish = await session.mainWindow.evaluate( () => document.documentElement.dir );
83+
expect( htmlDirEnglish ).toBe( 'ltr' );
84+
await expect( sidebar.addSiteButton ).toHaveText( 'Add site' );
85+
} );
86+
87+
test( 'persists selected language when re-opening app', async () => {
88+
const sidebar = new MainSidebar( session.mainWindow );
89+
90+
await changeLanguage( session.mainWindow, 'de' );
91+
await expect( sidebar.addSiteButton ).toHaveText( 'Website hinzufügen' );
92+
await session.restart();
93+
await session.mainWindow.waitForLoadState( 'domcontentloaded' );
94+
95+
const onboarding = new Onboarding( session.mainWindow );
96+
try {
97+
const visible = await onboarding.heading.isVisible( { timeout: 2000 } );
98+
if ( visible ) {
99+
await onboarding.continueButton.click();
100+
}
101+
} catch ( error ) {
102+
// Onboarding not visible, continue with test
103+
}
104+
105+
const whatsNewModal = new WhatsNewModal( session.mainWindow );
106+
try {
107+
const visible = await whatsNewModal.locator.isVisible( { timeout: 2000 } );
108+
if ( visible ) {
109+
await whatsNewModal.closeButton.click();
110+
}
111+
} catch ( error ) {
112+
// What's New modal not visible, continue with test
113+
}
114+
115+
const siteContent = new SiteContent( session.mainWindow, 'My WordPress Website' );
116+
await expect( siteContent.siteNameHeading ).toBeVisible( { timeout: 120_000 } );
117+
118+
const sidebarAfterRestart = new MainSidebar( session.mainWindow );
119+
await expect( sidebarAfterRestart.addSiteButton ).toHaveText( 'Website hinzufügen' );
120+
121+
await changeLanguage( session.mainWindow, 'en' );
122+
} );
123+
124+
test( 'created site language matches Studio language', async ( { page } ) => {
125+
const siteName = 'Localized-Site-Test';
126+
const sidebar = new MainSidebar( session.mainWindow );
127+
const addSiteModal = new AddSiteModal( session.mainWindow );
128+
129+
// Change to Japanese
130+
await changeLanguage( session.mainWindow, 'ja' );
131+
132+
// Verify language changed
133+
await expect( sidebar.addSiteButton ).toHaveText( 'サイトを追加' );
134+
135+
// Open add site modal
136+
await sidebar.addSiteButton.click();
137+
await expect( addSiteModal.locator ).toBeVisible( { timeout: 5000 } );
138+
139+
// Click "Create a site" option using data-testid
140+
await expect( addSiteModal.createSiteButton ).toBeVisible();
141+
// Button contains both title and description, so we check it contains the title
142+
await expect( addSiteModal.createSiteButton ).toContainText( 'サイトを作成' );
143+
await addSiteModal.createSiteButton.click();
144+
145+
// Fill in site name using data-testid
146+
await expect( addSiteModal.siteNameInput ).toBeVisible( { timeout: 5000 } );
147+
await addSiteModal.siteNameInput.fill( siteName );
148+
149+
// Click "Add site" button using data-testid (wait for it to be enabled)
150+
await expect( addSiteModal.addSiteButton ).toBeVisible();
151+
await expect( addSiteModal.addSiteButton ).toContainText( 'サイトを追加' );
152+
await expect( addSiteModal.addSiteButton ).toBeEnabled();
153+
await addSiteModal.addSiteButton.click();
154+
155+
// Wait for modal to close
156+
await expect( addSiteModal.locator ).not.toBeVisible( { timeout: 10_000 } );
157+
158+
// Wait for site to be created
159+
const siteContent = new SiteContent( session.mainWindow, siteName );
160+
await expect( siteContent.runningButton ).toBeAttached( { timeout: 120_000 } );
161+
162+
const settingsTabButton = session.mainWindow.getByRole( 'tab', { name: /Settings|/i } );
163+
await settingsTabButton.click();
164+
const copyWpAdminButton = session.mainWindow.getByTestId( 'copy-wp-admin-url' );
165+
await expect( copyWpAdminButton ).toBeVisible();
166+
await copyWpAdminButton.click();
167+
const wpAdminUrl = await session.electronApp.evaluate( ( app ) => app.clipboard.readText() );
168+
169+
// Check WordPress site language
170+
// Use getUrlWithAutoLogin to automatically authenticate with WordPress admin
171+
// This works cross-platform by appending authentication parameters to the URL
172+
const optionsGeneralUrl = wpAdminUrl + '/options-general.php';
173+
await page.goto( getUrlWithAutoLogin( optionsGeneralUrl ) );
174+
175+
const languageSelect = page.locator( '#WPLANG' );
176+
const selectedLanguage = await languageSelect.inputValue();
177+
178+
expect( selectedLanguage ).toBe( 'ja' );
179+
180+
await changeLanguage( session.mainWindow, 'en' );
181+
await session.electronApp.evaluate( ( { dialog } ) => {
182+
dialog.showMessageBox = async () => {
183+
return { response: 0, checkboxChecked: true };
184+
};
185+
} );
186+
187+
const siteContentEnglish = new SiteContent( session.mainWindow, siteName );
188+
const settingsTab = await siteContentEnglish.navigateToTab( 'Settings' );
189+
await settingsTab.openDeleteSiteModal();
190+
191+
// Wait for the confirmation dialog to be auto-confirmed and deletion to complete
192+
// Verify site was deleted by checking it no longer appears in sidebar
193+
const deletedSite = session.mainWindow.getByRole( 'button', { name: siteName, exact: true } );
194+
await expect( deletedSite ).not.toBeVisible( { timeout: 30_000 } );
195+
} );
196+
} );

e2e/overview-customize-links.test.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,15 @@ test.describe( 'Overview customize links', () => {
6060
await page.goto( getUrlWithAutoLogin( openedUrl ), {
6161
waitUntil: 'domcontentloaded',
6262
} );
63-
return page.url().replace( /%2F/g, '/' );
63+
// Decode URL-encoded characters to normalize the URL across platforms
64+
// Need to decode multiple times due to nested redirect_to parameters
65+
let url = page.url();
66+
let decoded = decodeURIComponent( url );
67+
while ( decoded !== url ) {
68+
url = decoded;
69+
decoded = decodeURIComponent( url );
70+
}
71+
return decoded;
6472
};
6573
test.describe( 'Block theme customize shortcut links', () => {
6674
test.beforeAll( async () => {
@@ -121,7 +129,10 @@ test.describe( 'Overview customize links', () => {
121129

122130
test( 'opens Styles shortcut', async ( { page } ) => {
123131
const redirectUrl = await openShortcut( page, 'Styles' );
124-
expect( redirectUrl ).toContain( '/wp-admin/site-editor.php?p=/styles' );
132+
// WordPress may use either path= or p= parameter depending on platform
133+
expect( redirectUrl ).toMatch(
134+
/\/wp-admin\/site-editor\.php\?(path=\/wp_global_styles|p=\/styles)/
135+
);
125136

126137
const headingLocator = page.getByRole( 'heading', {
127138
name: 'Design',
@@ -131,7 +142,10 @@ test.describe( 'Overview customize links', () => {
131142

132143
test( 'opens Patterns shortcut', async ( { page } ) => {
133144
const redirectUrl = await openShortcut( page, 'Patterns' );
134-
expect( redirectUrl ).toContain( '/wp-admin/site-editor.php?p=/pattern' );
145+
// WordPress may use either path= or p= parameter depending on platform
146+
expect( redirectUrl ).toMatch(
147+
/\/wp-admin\/site-editor\.php\?(path=\/patterns|p=\/pattern)/
148+
);
135149

136150
const headingLocator = page.getByRole( 'heading', {
137151
name: 'All patterns',
@@ -141,7 +155,10 @@ test.describe( 'Overview customize links', () => {
141155

142156
test( 'opens Navigation shortcut', async ( { page } ) => {
143157
const redirectUrl = await openShortcut( page, 'Navigation' );
144-
expect( redirectUrl ).toContain( '/wp-admin/site-editor.php?p=/navigation' );
158+
// WordPress may use either path= or p= parameter depending on platform
159+
expect( redirectUrl ).toMatch(
160+
/\/wp-admin\/site-editor\.php\?(path=\/navigation|p=\/navigation)/
161+
);
145162

146163
const headingLocator = page.getByRole( 'heading', {
147164
name: 'Navigation',
@@ -151,15 +168,19 @@ test.describe( 'Overview customize links', () => {
151168

152169
test( 'opens Templates shortcut', async ( { page } ) => {
153170
const redirectUrl = await openShortcut( page, 'Templates' );
154-
expect( redirectUrl ).toContain( '/wp-admin/site-editor.php?p=/template' );
171+
// WordPress may use either path= or p= parameter depending on platform
172+
expect( redirectUrl ).toMatch(
173+
/\/wp-admin\/site-editor\.php\?(path=\/wp_template|p=\/template)/
174+
);
155175

156176
const headingLocator = page.locator( 'h1', { hasText: 'Templates' } );
157177
await expect( headingLocator ).toBeVisible( { timeout: 120_000 } );
158178
} );
159179

160180
test( 'opens Pages shortcut', async ( { page } ) => {
161181
const redirectUrl = await openShortcut( page, 'Pages' );
162-
expect( redirectUrl ).toContain( '/wp-admin/site-editor.php?p=/page' );
182+
// WordPress may use either path= or p= parameter depending on platform
183+
expect( redirectUrl ).toMatch( /\/wp-admin\/site-editor\.php\?(path=\/page|p=\/page)/ );
163184

164185
const headingLocator = page.locator( 'h1', { hasText: 'Pages' } );
165186
await expect( headingLocator ).toBeVisible( { timeout: 120_000 } );

e2e/page-objects/add-site-modal.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ export default class AddSiteModal {
99
}
1010

1111
get createSiteButton() {
12-
return this.page.locator( 'button:has-text("Create a site")' ).first();
12+
return this.page.getByTestId( 'create-site-option-button' );
1313
}
1414

1515
get blueprintButton() {
1616
return this.page.locator( 'button:has-text("Start from a Blueprint")' ).first();
1717
}
1818

1919
get continueButton() {
20-
return this.locator.getByRole( 'button', { name: 'Continue' } );
20+
return this.page.getByTestId( 'stepper-action-button' );
2121
}
2222

2323
get fileInput() {
@@ -37,7 +37,7 @@ export default class AddSiteModal {
3737
}
3838

3939
get addSiteButton() {
40-
return this.locator.getByRole( 'button', { name: 'Add site' } );
40+
return this.page.getByTestId( 'stepper-action-button' );
4141
}
4242

4343
async selectLocalPathForTesting() {

e2e/page-objects/main-sidebar.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export default class MainSidebar {
1010
}
1111

1212
get addSiteButton() {
13-
return this.locator.getByRole( 'button', { name: 'Add site' } );
13+
return this.page.getByTestId( 'add-site-button' );
1414
}
1515

1616
getSiteNavButton( siteName: string ) {

e2e/page-objects/site-content.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ export default class SiteContent {
2424
}
2525

2626
get runningButton() {
27-
return this.locator.getByRole( 'button', { name: 'Running' } );
27+
// Try new data-testid first, fall back to role-based selector for trunk compatibility
28+
return this.locator
29+
.getByTestId( 'site-status-running' )
30+
.or( this.locator.getByRole( 'button', { name: 'Running' } ) );
2831
}
2932

3033
get frontendButton() {

e2e/page-objects/site-form.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ export default class SiteForm {
88
}
99

1010
get siteNameInput() {
11-
return this.page.getByLabel( 'Site name' );
11+
// Try new data-testid first, fall back to label-based selector for trunk compatibility
12+
return this.page
13+
.getByTestId( 'site-name-input' )
14+
.or( this.page.locator( 'label:has-text("Site name") input' ) );
1215
}
1316

1417
get localPathInput() {

0 commit comments

Comments
 (0)