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 +21 -5
- package/generators/cypress/files.js +11 -0
- package/generators/cypress/generator.js +2 -1
- package/generators/cypress/templates/README.md.jhi.playwright.ejs +18 -0
- package/generators/cypress/templates/eslint.config.ts.jhi.playwright.ejs +16 -0
- package/generators/cypress/templates/playwright.config.ts.ejs +1 -1
- package/generators/cypress/templates/proxy.config.playwright.mjs.ejs +26 -0
- package/generators/cypress/templates/src/test/javascript/cypress/e2e/account/login-page.spec.ts.ejs +1 -1
- package/generators/cypress/templates/src/test/javascript/cypress/e2e/account/reset-password-page.spec.ts.ejs +1 -4
- package/generators/cypress/templates/src/test/javascript/cypress/e2e/account/settings-page.spec.ts.ejs +76 -1
- package/generators/cypress/templates/src/test/javascript/cypress/support/commands.ts.ejs +64 -10
- package/generators/cypress/templates/src/test/javascript/cypress/support/navbar.ts.ejs +7 -2
- package/generators/cypress/templates/src/test/javascript/cypress/support/oauth2.ts.ejs +76 -30
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
# generator-jhipster-playwright
|
|
2
2
|
|
|
3
|
+
[](https://github.com/sreeshanth-soma/generator-jhipster-playwright/actions/workflows/ci.yml)
|
|
4
|
+
[](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
|
-
|
|
12
|
+
### Supported Matrix
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
| Framework | JWT | Session | OAuth2 |
|
|
15
|
+
|-----------|-----|---------|--------|
|
|
16
|
+
| React | Yes | Yes | Yes |
|
|
17
|
+
| Angular | Yes | Yes | Yes |
|
|
18
|
+
| Vue | Yes | Yes | Yes |
|
|
13
19
|
|
|
14
|
-
|
|
20
|
+
All 9 combinations are verified in CI against freshly generated JHipster applications. OAuth2 tests run against a Keycloak instance.
|
|
15
21
|
|
|
16
|
-
|
|
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
|
+
};
|
package/generators/cypress/templates/src/test/javascript/cypress/e2e/account/login-page.spec.ts.ejs
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
await page.
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
11
|
-
idToken: string;
|
|
10
|
+
url: string;
|
|
12
11
|
}
|
|
13
12
|
|
|
14
13
|
/**
|
|
15
|
-
*
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
76
|
+
* Login via Okta.
|
|
46
77
|
*/
|
|
47
|
-
export async function
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
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"
|