generator-jhipster-playwright 1.0.0 → 1.2.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,28 @@
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:
12
+ ### Supported Matrix
10
13
 
11
- - React + JWT: `53 passed`, `0 skipped`
12
- - Angular + JWT: `59 passed`, `0 failed`, `0 skipped`
14
+ | Framework | JWT | Session | OAuth2 |
15
+ |-----------|-----|---------|--------|
16
+ | React | Yes | Yes | Yes |
17
+ | Angular | Yes | Yes | Yes |
18
+ | Vue | Yes | Yes | Yes |
13
19
 
14
- ## Installation
20
+ All 9 combinations are verified in CI against freshly generated JHipster applications. OAuth2 tests run against a Keycloak instance.
15
21
 
16
- To install or update this blueprint:
22
+ ## Installation
17
23
 
18
24
  ```bash
25
+ npm install -g generator-jhipster@9.0.0
19
26
  npm install -g generator-jhipster-playwright
20
27
  ```
21
28
 
@@ -60,6 +67,7 @@ application {
60
67
  config {
61
68
  baseName myApp
62
69
  clientFramework react
70
+ authenticationType jwt
63
71
  testFrameworks [cypress]
64
72
  }
65
73
  }
@@ -87,6 +95,12 @@ npx playwright test
87
95
 
88
96
  The generated `playwright.config.ts` starts the frontend dev server automatically. The Spring Boot backend still needs to be running before the tests execute.
89
97
 
98
+ For OAuth2 applications, a Keycloak instance must be running before the backend starts:
99
+
100
+ ```bash
101
+ docker compose -f src/main/docker/keycloak.yml up -d
102
+ ```
103
+
90
104
  ### Generated Output
91
105
 
92
106
  The blueprint writes:
@@ -98,6 +112,8 @@ The blueprint writes:
98
112
 
99
113
  For Angular applications, the blueprint also adds `@popperjs/core` to the generated app dependencies so the generated frontend has the required Popper peer dependency available.
100
114
 
115
+ For Angular session-auth applications, a custom `proxy.config.playwright.mjs` is generated to avoid proxying lazy-loaded route chunks.
116
+
101
117
  ## Local Development
102
118
 
103
119
  To work on the blueprint locally:
@@ -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
  });
@@ -8,7 +8,7 @@
8
8
  * - clientFrameworkAngular, clientFrameworkReact, clientFrameworkVue
9
9
  */
10
10
  import type { Page, APIRequestContext, APIResponse } from '@playwright/test';
11
- import { expect, request as pwRequest } from '@playwright/test';
11
+ import { expect } from '@playwright/test';
12
12
 
13
13
  // ***********************************************
14
14
  // Begin Specific Selector Attributes
@@ -153,47 +153,101 @@ export async function login(page: Page, request: APIRequestContext, username: st
153
153
  await waitForAppShell(page);
154
154
  }
155
155
  <%_ } else if (authenticationTypeOauth2) { _%>
156
+ // Session cookies captured after browser OAuth2 login for API requests.
157
+ let _sessionCookie: string | undefined;
158
+ let _xsrfToken: string | undefined;
159
+
156
160
  /**
157
161
  * Perform authenticated API request (OAuth2).
162
+ * Uses session cookies captured from the browser after login.
158
163
  */
159
164
  export async function authenticatedRequest(
160
165
  request: APIRequestContext,
161
166
  data: { method?: string; url: string; data?: unknown },
162
167
  ): Promise<APIResponse> {
163
- return request.fetch(data.url, { method: data.method ?? 'GET', data: data.data });
168
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
169
+ if (_sessionCookie) {
170
+ headers['Cookie'] = _sessionCookie;
171
+ }
172
+ if (_xsrfToken) {
173
+ headers['X-XSRF-TOKEN'] = _xsrfToken;
174
+ }
175
+ return request.fetch(data.url, {
176
+ method: data.method ?? 'GET',
177
+ data: data.data,
178
+ headers,
179
+ });
164
180
  }
165
181
 
166
182
  /**
167
- * Login via OAuth2 (Keycloak).
183
+ * Login via OAuth2 (Keycloak, Okta, or Auth0 — detected from redirect URL).
168
184
  */
169
185
  export async function login(page: Page, request: APIRequestContext, username: string, password: string): Promise<void> {
170
186
  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();
187
+ // Wait until we've left the JHipster app (landed on the identity provider)
188
+ await page.waitForURL(url => !url.toString().includes('/oauth2/authorization'), { timeout: 15000 });
189
+ const currentUrl = page.url();
190
+ if (currentUrl.includes('okta')) {
191
+ await page.locator('#okta-signin-username').fill(username);
192
+ await page.locator('#okta-signin-password').fill(password);
193
+ await page.locator('[type="submit"]').first().click();
194
+ } else if (currentUrl.includes('auth0')) {
195
+ await page.locator('#username').fill(username);
196
+ await page.locator('#password').fill(password);
197
+ await page.locator('button[type="submit"]').click();
198
+ } else {
199
+ // Keycloak (default)
200
+ await page.waitForSelector('#kc-form-login', { timeout: 15000 });
201
+ await page.locator('#username').fill(username);
202
+ await page.locator('#password').fill(password);
203
+ await page.locator('#kc-login').click();
204
+ }
175
205
  await page.waitForURL('**/', { timeout: 15000 });
176
206
  await waitForAppShell(page);
207
+ // Capture session cookies so authenticatedRequest() can make API calls.
208
+ const cookies = await page.context().cookies();
209
+ _sessionCookie = cookies.map(c => `${c.name}=${c.value}`).join('; ');
210
+ _xsrfToken = cookies.find(c => c.name === 'XSRF-TOKEN')?.value;
177
211
  }
178
212
  <%_ } else { _%>
179
213
  /**
180
214
  * Perform authenticated API request (Session).
215
+ * Reads the XSRF-TOKEN cookie and sends it as X-XSRF-TOKEN header for CSRF-protected endpoints.
181
216
  */
182
217
  export async function authenticatedRequest(
183
218
  request: APIRequestContext,
184
219
  data: { method?: string; url: string; data?: unknown },
185
220
  ): Promise<APIResponse> {
186
- return request.fetch(data.url, { method: data.method ?? 'GET', data: data.data });
221
+ const storageState = await request.storageState();
222
+ const xsrfCookie = storageState.cookies.find(c => c.name === 'XSRF-TOKEN');
223
+ return request.fetch(data.url, {
224
+ method: data.method ?? 'GET',
225
+ data: data.data,
226
+ headers: {
227
+ ...(xsrfCookie ? { 'X-XSRF-TOKEN': xsrfCookie.value } : {}),
228
+ },
229
+ });
187
230
  }
188
231
 
189
232
  /**
190
233
  * Login via Session auth.
234
+ * Spring Security CSRF requires the XSRF-TOKEN to be included as X-XSRF-TOKEN
235
+ * header on the authentication POST. We first hit any API endpoint to receive
236
+ * the token cookie, then POST credentials with that token.
237
+ * The session cookie lives in the `request` context; we copy it to the
238
+ * page's browser context so that page navigations are also authenticated.
191
239
  */
192
240
  export async function login(page: Page, request: APIRequestContext, username: string, password: string): Promise<void> {
241
+ // Trigger CSRF cookie issuance (401 is fine — we just need the XSRF-TOKEN cookie)
242
+ await request.get('/api/account');
243
+ const { cookies: initialCookies } = await request.storageState();
244
+ const xsrfCookie = initialCookies.find(c => c.name === 'XSRF-TOKEN');
193
245
  await request.post('/api/authentication', {
194
- data: { username, password },
195
- form: true,
246
+ form: { username, password },
247
+ headers: xsrfCookie ? { 'X-XSRF-TOKEN': xsrfCookie.value } : {},
196
248
  });
249
+ const { cookies } = await request.storageState();
250
+ await page.context().addCookies(cookies);
197
251
  await page.goto('/');
198
252
  await waitForAppShell(page);
199
253
  }
@@ -19,9 +19,14 @@ import {
19
19
 
20
20
  async function clickDropdownItem(menu: Locator, item: string): Promise<void> {
21
21
  await expect(menu).toBeVisible();
22
- await menu.click();
23
22
  const itemLocator = menu.locator(item);
24
- await expect(itemLocator).toBeVisible();
23
+ // Retry open-then-check: the dropdown can close due to animation or re-render.
24
+ await expect(async () => {
25
+ if (!(await itemLocator.isVisible())) {
26
+ await menu.click();
27
+ }
28
+ await expect(itemLocator).toBeVisible();
29
+ }).toPass({ timeout: 10_000 });
25
30
  await itemLocator.click();
26
31
  }
27
32
 
@@ -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.2.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"