generator-jhipster-playwright 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,21 +1,20 @@
1
1
  # generator-jhipster-playwright
2
2
 
3
+ [![CI](https://github.com/sreeshanth-soma/generator-jhipster-playwright/actions/workflows/ci.yml/badge.svg)](https://github.com/sreeshanth-soma/generator-jhipster-playwright/actions/workflows/ci.yml)
4
+ [![npm version](https://badge.fury.io/js/generator-jhipster-playwright.svg)](https://www.npmjs.com/package/generator-jhipster-playwright)
5
+
3
6
  > A JHipster blueprint that replaces the generated Cypress end-to-end suite with Playwright.
4
7
 
5
8
  ## Introduction
6
9
 
7
10
  This is a JHipster blueprint. It overrides the `cypress` sub-generator and writes Playwright files instead of Cypress files for generated applications.
8
11
 
9
- Verified against fresh generated JHipster applications:
10
-
11
- - React + JWT: `53 passed`, `0 skipped`
12
- - Angular + JWT: `59 passed`, `0 failed`, `0 skipped`
12
+ Verified against freshly generated React, Angular, and Vue JHipster applications — all tests passing, no failures or flakiness.
13
13
 
14
14
  ## Installation
15
15
 
16
- To install or update this blueprint:
17
-
18
16
  ```bash
17
+ npm install -g generator-jhipster@9.0.0
19
18
  npm install -g generator-jhipster-playwright
20
19
  ```
21
20
 
@@ -7,14 +7,25 @@
7
7
  * renameTo: (ctx, file) => output path in generated app
8
8
  * templates: ['filename'] — JHipster auto-appends .ejs
9
9
  */
10
+ import { clientRootTemplatesBlock } from 'generator-jhipster/generators/client/support';
10
11
 
11
12
  const PLAYWRIGHT_TEMPLATE_SOURCE_DIR = 'src/test/javascript/cypress/';
12
13
 
13
14
  export const playwrightFiles = {
14
15
  common: [
16
+ {
17
+ templates: ['README.md.jhi.playwright'],
18
+ },
15
19
  {
16
20
  templates: ['playwright.config.ts'],
17
21
  },
22
+ {
23
+ condition: generator => generator.clientFrameworkAngular && generator.authenticationTypeSession,
24
+ templates: ['proxy.config.playwright.mjs'],
25
+ },
26
+ clientRootTemplatesBlock({
27
+ templates: ['eslint.config.ts.jhi.playwright'],
28
+ }),
18
29
  ],
19
30
  clientTestFw: [
20
31
  {
@@ -90,7 +90,8 @@ export default class extends BaseApplicationGenerator {
90
90
  );
91
91
  const dependencies = packageJsonStorage.createStorage('dependencies');
92
92
  const devDependencies = packageJsonStorage.createStorage('devDependencies');
93
- devDependencies.set('@playwright/test', '1.58.2');
93
+ devDependencies.set('@playwright/test', '^1.58.2');
94
+ devDependencies.set('eslint-plugin-playwright', '^2.9.0');
94
95
 
95
96
  if (application.clientFrameworkAngular) {
96
97
  // ng-bootstrap and Bootstrap both declare Popper as a peer dependency.
@@ -0,0 +1,18 @@
1
+ <%#
2
+ This is a fragment file merged into README.md via JHipster's fragment system.
3
+ -%>
4
+ <&_ if (fragment.testingSection) { -&>
5
+ #### E2E tests
6
+
7
+ UI end-to-end tests are powered by [Playwright][]. They're located in [<%= playwrightDir %>](<%= playwrightDir %>) and can be run by starting Spring Boot in one terminal (`<%= nodePackageManagerCommand %> run app:start`) and running the tests (`<%= nodePackageManagerCommand %> run e2e:headless`) in a second one.
8
+
9
+ Before running Playwright tests, you can specify user credentials by setting the `E2E_USERNAME` and `E2E_PASSWORD` environment variables.
10
+
11
+ ```bash
12
+ export E2E_USERNAME="<your-username>"
13
+ export E2E_PASSWORD="<your-password>"
14
+ ```
15
+ <&_ } -&>
16
+ <&_ if (fragment.referenceSection) { -&>
17
+ - [Playwright](https://playwright.dev/)
18
+ <&_ } -&>
@@ -0,0 +1,16 @@
1
+ <&_ if (fragment.importsSection) { -&>
2
+ import playwright from 'eslint-plugin-playwright';
3
+ <&_ } -&>
4
+ <&_ if (fragment.configSection) { -&>
5
+ {
6
+ files: ['<%= this.relativeDir(clientRootDir, playwrightDir) %>**/*.ts'],
7
+ ...playwright.configs['flat/recommended'],
8
+ rules: {
9
+ '@typescript-eslint/no-explicit-any': 'off',
10
+ '@typescript-eslint/no-unsafe-argument': 'off',
11
+ '@typescript-eslint/no-unsafe-assignment': 'off',
12
+ '@typescript-eslint/no-unsafe-call': 'off',
13
+ '@typescript-eslint/no-unsafe-member-access': 'off',
14
+ },
15
+ },
16
+ <&_ } -&>
@@ -22,7 +22,7 @@ export default defineConfig({
22
22
  },
23
23
  ],
24
24
  webServer: {
25
- command: 'npm run webapp:dev<% if (clientFrameworkAngular) { %> -- --open=false<% } %>',
25
+ command: 'npm run webapp:dev<% if (clientFrameworkAngular) { %> -- --open=false<% if (authenticationTypeSession) { %> --proxy-config proxy.config.playwright.mjs<% } %><% } %>',
26
26
  url: 'http://localhost:<%= devServerPortProxy || devServerPort || gatewayServerPort || serverPort %>/',
27
27
  reuseExistingServer: !process.env.CI,
28
28
  timeout: 120_000,
@@ -0,0 +1,26 @@
1
+ <%#
2
+ Playwright-specific Vite proxy config for Angular session auth apps.
3
+
4
+ The standard proxy.config.mjs uses the regex '^/(api|...|login)' which
5
+ incorrectly matches lazy-loaded route chunks named login-<hash>.js,
6
+ causing the Angular login component to fail with a 404. This file uses
7
+ 'login(?=$|/)' so only the exact /login path (and sub-paths) is proxied.
8
+ -%>
9
+ const backendHost = '127.0.0.1';
10
+ const backendPort = <%= applicationTypeMicroservice ? gatewayServerPort : serverPort %>;
11
+
12
+ /**
13
+ * @type {import('vite').CommonServerOptions['proxy']}
14
+ */
15
+ export default {
16
+ <%_ if (communicationSpringWebsocket) { _%>
17
+ '/websocket': {
18
+ target: `ws://${backendHost}:${backendPort}`,
19
+ ws: true,
20
+ },
21
+ <%_ } _%>
22
+ '^/(api|management|v3/api-docs<% if (locals.databaseTypeSql && locals.devDatabaseTypeH2Any) { %>|h2-console<% } %><% if (authenticationTypeOauth2) { %>|oauth2<% } %><% if (authenticationTypeSession || authenticationTypeOauth2) { %>|login(?=$|/)<% } %><% if (applicationTypeGateway || applicationTypeMicroservice) { %>|services<% } %>)': {
23
+ target: `http://${backendHost}:${backendPort}`,
24
+ xfwd: true,
25
+ },
26
+ };
@@ -30,7 +30,7 @@ test.describe('login page', () => {
30
30
  test('greets with signin', async ({ page }) => {
31
31
  await expect(page.locator(titleLoginSelector)).toBeVisible();
32
32
  });
33
- <%_ if (clientFrameworkAngular) { _%>
33
+ <%_ if (clientFrameworkAngular && !authenticationTypeSession) { _%>
34
34
 
35
35
  test('greets visiting /login directly', async ({ page }) => {
36
36
  await page.goto('/login');
@@ -46,11 +46,8 @@ test.describe('forgot your password', () => {
46
46
  test('should be able to init reset password', async ({ page }) => {
47
47
  await page.locator(emailResetPasswordSelector).fill('user@gmail.com');
48
48
  const responsePromise = page.waitForResponse('**/api/account/reset-password/init');
49
- <%_ if (clientFrameworkReact) { _%>
49
+ await expect(page.locator(submitInitResetPasswordSelector)).toBeEnabled({ timeout: 5000 });
50
50
  await page.locator(submitInitResetPasswordSelector).click();
51
- <%_ } else { _%>
52
- await page.locator(submitInitResetPasswordSelector).click({ force: true });
53
- <%_ } _%>
54
51
  const response = await responsePromise;
55
52
  expect(response.status()).toBe(200);
56
53
  });
@@ -1,5 +1,5 @@
1
1
  <%#
2
- Source EJS variables: ZERO — settings-page.cy.ts.ejs has no EJS variables.
2
+ Source EJS variables: authenticationTypeJwt
3
3
  -%>
4
4
  import { test, expect } from '@playwright/test';
5
5
  import {
@@ -10,6 +10,9 @@ import {
10
10
  credentials,
11
11
  login,
12
12
  authenticatedRequest,
13
+ <%_ if (authenticationTypeJwt) { _%>
14
+ getAuthToken,
15
+ <%_ } _%>
13
16
  } from '../../support/commands';
14
17
  import { clickOnSettingsItem } from '../../support/navbar';
15
18
  import type { Account } from '../../support/account';
@@ -82,4 +85,76 @@ test.describe('/account/settings', () => {
82
85
  const response = await responsePromise;
83
86
  expect(response.status()).toBe(200);
84
87
  });
88
+
89
+ test.describe('if there is another user with an email', () => {
90
+ let originalAdminAccount: Account;
91
+ const testAdminEmail = 'admin@localhost.fr';
92
+
93
+ test.beforeAll(async ({ request }) => {
94
+ <%_ if (authenticationTypeJwt) { _%>
95
+ const adminToken = await getAuthToken(request, adminUsername, adminPassword);
96
+ const accountResp = await request.get('/api/account', {
97
+ headers: { Authorization: `Bearer ${adminToken}` },
98
+ });
99
+ originalAdminAccount = await accountResp.json() as Account;
100
+ const saveResp = await request.post('/api/account', {
101
+ data: { ...originalAdminAccount, email: testAdminEmail },
102
+ headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
103
+ });
104
+ expect(saveResp.status()).toBe(200);
105
+ <%_ } else { _%>
106
+ await request.get('/api/account');
107
+ const { cookies: preLoginCookies } = await request.storageState();
108
+ const preLoginXsrf = preLoginCookies.find(c => c.name === 'XSRF-TOKEN');
109
+ await request.post('/api/authentication', {
110
+ form: { username: adminUsername, password: adminPassword },
111
+ headers: preLoginXsrf ? { 'X-XSRF-TOKEN': preLoginXsrf.value } : {},
112
+ });
113
+ const { cookies } = await request.storageState();
114
+ const xsrfCookie = cookies.find(c => c.name === 'XSRF-TOKEN');
115
+ const headers = { 'Content-Type': 'application/json', ...(xsrfCookie ? { 'X-XSRF-TOKEN': xsrfCookie.value } : {}) };
116
+ const accountResp = await request.get('/api/account', { headers });
117
+ originalAdminAccount = await accountResp.json() as Account;
118
+ const saveResp = await request.post('/api/account', {
119
+ data: { ...originalAdminAccount, email: testAdminEmail },
120
+ headers,
121
+ });
122
+ expect(saveResp.status()).toBe(200);
123
+ <%_ } _%>
124
+ });
125
+
126
+ test.afterAll(async ({ request }) => {
127
+ <%_ if (authenticationTypeJwt) { _%>
128
+ const adminToken = await getAuthToken(request, adminUsername, adminPassword);
129
+ await request.post('/api/account', {
130
+ data: originalAdminAccount,
131
+ headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
132
+ });
133
+ <%_ } else { _%>
134
+ await request.get('/api/account');
135
+ const { cookies: preLoginCookies } = await request.storageState();
136
+ const preLoginXsrf = preLoginCookies.find(c => c.name === 'XSRF-TOKEN');
137
+ await request.post('/api/authentication', {
138
+ form: { username: adminUsername, password: adminPassword },
139
+ headers: preLoginXsrf ? { 'X-XSRF-TOKEN': preLoginXsrf.value } : {},
140
+ });
141
+ const { cookies } = await request.storageState();
142
+ const xsrfCookie = cookies.find(c => c.name === 'XSRF-TOKEN');
143
+ await request.post('/api/account', {
144
+ data: originalAdminAccount,
145
+ headers: { 'Content-Type': 'application/json', ...(xsrfCookie ? { 'X-XSRF-TOKEN': xsrfCookie.value } : {}) },
146
+ });
147
+ <%_ } _%>
148
+ });
149
+
150
+ test("should not be able to change 'user' email to same value", async ({ page }) => {
151
+ await page.locator(emailSettingsSelector).clear();
152
+ await page.locator(firstNameSettingsSelector).fill('jhipster');
153
+ await page.locator(emailSettingsSelector).fill(testAdminEmail);
154
+ const responsePromise = page.waitForResponse('**/api/account');
155
+ await page.locator(submitSettingsSelector).click();
156
+ const response = await responsePromise;
157
+ expect(response.status()).toBe(400);
158
+ });
159
+ });
85
160
  });
@@ -164,36 +164,70 @@ export async function authenticatedRequest(
164
164
  }
165
165
 
166
166
  /**
167
- * Login via OAuth2 (Keycloak).
167
+ * Login via OAuth2 (Keycloak, Okta, or Auth0 — detected from redirect URL).
168
168
  */
169
169
  export async function login(page: Page, request: APIRequestContext, username: string, password: string): Promise<void> {
170
170
  await page.goto('/oauth2/authorization/oidc');
171
- await page.waitForSelector('#kc-form-login', { timeout: 15000 });
172
- await page.locator('#username').fill(username);
173
- await page.locator('#password').fill(password);
174
- await page.locator('#kc-login').click();
171
+ // Wait until we've left the JHipster app (landed on the identity provider)
172
+ await page.waitForURL(url => !url.toString().includes('/oauth2/authorization'), { timeout: 15000 });
173
+ const currentUrl = page.url();
174
+ if (currentUrl.includes('okta')) {
175
+ await page.locator('#okta-signin-username').fill(username);
176
+ await page.locator('#okta-signin-password').fill(password);
177
+ await page.locator('[type="submit"]').first().click();
178
+ } else if (currentUrl.includes('auth0')) {
179
+ await page.locator('#username').fill(username);
180
+ await page.locator('#password').fill(password);
181
+ await page.locator('button[type="submit"]').click();
182
+ } else {
183
+ // Keycloak (default)
184
+ await page.waitForSelector('#kc-form-login', { timeout: 15000 });
185
+ await page.locator('#username').fill(username);
186
+ await page.locator('#password').fill(password);
187
+ await page.locator('#kc-login').click();
188
+ }
175
189
  await page.waitForURL('**/', { timeout: 15000 });
176
190
  await waitForAppShell(page);
177
191
  }
178
192
  <%_ } else { _%>
179
193
  /**
180
194
  * Perform authenticated API request (Session).
195
+ * Reads the XSRF-TOKEN cookie and sends it as X-XSRF-TOKEN header for CSRF-protected endpoints.
181
196
  */
182
197
  export async function authenticatedRequest(
183
198
  request: APIRequestContext,
184
199
  data: { method?: string; url: string; data?: unknown },
185
200
  ): Promise<APIResponse> {
186
- return request.fetch(data.url, { method: data.method ?? 'GET', data: data.data });
201
+ const storageState = await request.storageState();
202
+ const xsrfCookie = storageState.cookies.find(c => c.name === 'XSRF-TOKEN');
203
+ return request.fetch(data.url, {
204
+ method: data.method ?? 'GET',
205
+ data: data.data,
206
+ headers: {
207
+ ...(xsrfCookie ? { 'X-XSRF-TOKEN': xsrfCookie.value } : {}),
208
+ },
209
+ });
187
210
  }
188
211
 
189
212
  /**
190
213
  * Login via Session auth.
214
+ * Spring Security CSRF requires the XSRF-TOKEN to be included as X-XSRF-TOKEN
215
+ * header on the authentication POST. We first hit any API endpoint to receive
216
+ * the token cookie, then POST credentials with that token.
217
+ * The session cookie lives in the `request` context; we copy it to the
218
+ * page's browser context so that page navigations are also authenticated.
191
219
  */
192
220
  export async function login(page: Page, request: APIRequestContext, username: string, password: string): Promise<void> {
221
+ // Trigger CSRF cookie issuance (401 is fine — we just need the XSRF-TOKEN cookie)
222
+ await request.get('/api/account');
223
+ const { cookies: initialCookies } = await request.storageState();
224
+ const xsrfCookie = initialCookies.find(c => c.name === 'XSRF-TOKEN');
193
225
  await request.post('/api/authentication', {
194
- data: { username, password },
195
- form: true,
226
+ form: { username, password },
227
+ headers: xsrfCookie ? { 'X-XSRF-TOKEN': xsrfCookie.value } : {},
196
228
  });
229
+ const { cookies } = await request.storageState();
230
+ await page.context().addCookies(cookies);
197
231
  await page.goto('/');
198
232
  await waitForAppShell(page);
199
233
  }
@@ -2,63 +2,109 @@
2
2
  * Playwright OAuth2 support helpers
3
3
  * Translated from Cypress oauth2.ts
4
4
  *
5
- * This module handles OAuth2/Keycloak authentication flows.
5
+ * Supports Keycloak, Okta, and Auth0.
6
6
  */
7
7
  import type { Page, APIRequestContext } from '@playwright/test';
8
8
 
9
9
  export interface OAuth2Data {
10
- accessToken: string;
11
- idToken: string;
10
+ url: string;
12
11
  }
13
12
 
14
13
  /**
15
- * Initiate OAuth2 login flow and return tokens.
16
- * Translates the Cypress OAuth2 command chain:
17
- * 1. GET /oauth2/authorization/oidc (follow redirects to Keycloak)
18
- * 2. Parse the Keycloak login form
19
- * 3. POST credentials to Keycloak
20
- * 4. Follow redirects back to the app
14
+ * Get the OAuth2 provider redirect URL without following it.
21
15
  */
22
- export async function oauth2Login(
16
+ export async function getOauth2Data(request: APIRequestContext): Promise<OAuth2Data> {
17
+ const response = await request.get('/oauth2/authorization/oidc', { maxRedirects: 0 });
18
+ const location = response.headers()['location'] ?? '';
19
+ return { url: location };
20
+ }
21
+
22
+ /**
23
+ * Login via the appropriate OAuth2 provider (Keycloak, Okta, or Auth0).
24
+ * Provider is detected from the redirect URL.
25
+ */
26
+ export async function oauthLogin(
23
27
  page: Page,
28
+ oauth2Data: OAuth2Data,
24
29
  username: string,
25
30
  password: string,
26
31
  ): Promise<void> {
27
- // Navigate to the OAuth2 login endpoint
28
- await page.goto('/oauth2/authorization/oidc');
32
+ const url = oauth2Data.url;
33
+ if (url.includes('okta')) {
34
+ await oktaLogin(page, oauth2Data, username, password);
35
+ } else if (url.includes('auth0')) {
36
+ await auth0Login(page, oauth2Data, username, password);
37
+ } else {
38
+ await keycloakLogin(page, oauth2Data, username, password);
39
+ }
40
+ }
29
41
 
30
- // Wait for Keycloak login form
42
+ /**
43
+ * Login via Keycloak.
44
+ */
45
+ export async function keycloakLogin(
46
+ page: Page,
47
+ oauth2Data: OAuth2Data,
48
+ username: string,
49
+ password: string,
50
+ ): Promise<void> {
51
+ await page.goto(oauth2Data.url);
31
52
  await page.waitForSelector('#kc-form-login', { timeout: 15000 });
32
-
33
- // Fill in credentials
34
53
  await page.locator('#username').fill(username);
35
54
  await page.locator('#password').fill(password);
36
-
37
- // Submit the form
38
55
  await page.locator('#kc-login').click();
56
+ await page.waitForURL('**/', { timeout: 15000 });
57
+ }
39
58
 
40
- // Wait for redirect back to the app
59
+ /**
60
+ * Login via Auth0.
61
+ */
62
+ export async function auth0Login(
63
+ page: Page,
64
+ oauth2Data: OAuth2Data,
65
+ username: string,
66
+ password: string,
67
+ ): Promise<void> {
68
+ await page.goto(oauth2Data.url);
69
+ await page.locator('#username').fill(username);
70
+ await page.locator('#password').fill(password);
71
+ await page.locator('button[type="submit"]').click();
41
72
  await page.waitForURL('**/', { timeout: 15000 });
42
73
  }
43
74
 
44
75
  /**
45
- * Get OAuth2 tokens from the current session.
76
+ * Login via Okta.
46
77
  */
47
- export async function getOAuth2Data(request: APIRequestContext): Promise<OAuth2Data | null> {
48
- const response = await request.get('/api/account', { failOnStatusCode: false });
49
- if (response.ok()) {
50
- return {
51
- accessToken: '',
52
- idToken: '',
53
- };
54
- }
55
- return null;
78
+ export async function oktaLogin(
79
+ page: Page,
80
+ oauth2Data: OAuth2Data,
81
+ username: string,
82
+ password: string,
83
+ ): Promise<void> {
84
+ await page.goto(oauth2Data.url);
85
+ await page.locator('#okta-signin-username').fill(username);
86
+ await page.locator('#okta-signin-password').fill(password);
87
+ await page.locator('[type="submit"]').first().click();
88
+ await page.waitForURL('**/', { timeout: 15000 });
56
89
  }
57
90
 
58
91
  /**
59
- * Logout from OAuth2/Keycloak session.
92
+ * Logout from OAuth2 session.
93
+ * POSTs to /api/logout with CSRF token and follows the provider logout URL.
60
94
  */
61
95
  export async function oauth2Logout(page: Page): Promise<void> {
62
- await page.goto('/api/logout');
96
+ const cookies = await page.context().cookies();
97
+ const xsrfCookie = cookies.find(c => c.name === 'XSRF-TOKEN');
98
+ const origin = new URL(page.url()).origin;
99
+ const response = await page.request.post('/api/logout', {
100
+ headers: {
101
+ 'X-XSRF-TOKEN': xsrfCookie?.value ?? '',
102
+ Origin: origin,
103
+ },
104
+ });
105
+ const body = await response.json().catch(() => ({}));
106
+ if (body.logoutUrl) {
107
+ await page.goto(body.logoutUrl);
108
+ }
63
109
  await page.waitForURL('**/', { timeout: 15000 });
64
110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "generator-jhipster-playwright",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "JHipster Playwright E2E blueprint - replaces Cypress with Playwright for end-to-end testing",
5
5
  "keywords": [
6
6
  "yeoman-generator",
@@ -30,10 +30,11 @@
30
30
  "smoke": "node --check cli/cli.cjs && node --check generators/cypress/command.js && node --check generators/cypress/files.js && node --check generators/cypress/generator.js && node --check generators/cypress/index.js && node --input-type=module -e \"await import('./generators/cypress/index.js'); await import('./generators/cypress/generator.js'); await import('./generators/cypress/files.js'); await import('./generators/cypress/command.js');\"",
31
31
  "test": "npm run lint && npm run smoke"
32
32
  },
33
- "dependencies": {
33
+ "peerDependencies": {
34
34
  "generator-jhipster": "9.0.0"
35
35
  },
36
36
  "devDependencies": {
37
+ "generator-jhipster": "9.0.0",
37
38
  "@playwright/test": "^1.58.2",
38
39
  "eslint": "9.39.2",
39
40
  "yeoman-test": ">=8.2.0"