diff --git a/generators/client/command.ts b/generators/client/command.ts index 4e0a52a1aecf..965313674fb1 100644 --- a/generators/client/command.ts +++ b/generators/client/command.ts @@ -24,7 +24,7 @@ import { ALPHANUMERIC_PATTERN } from '../../lib/constants/jdl.ts'; import { APPLICATION_TYPE_GATEWAY, APPLICATION_TYPE_MICROSERVICE } from '../../lib/core/application-types.ts'; import { clientFrameworkTypes, testFrameworkTypes } from '../../lib/jhipster/index.ts'; -const { CYPRESS } = testFrameworkTypes; +const { CYPRESS, PLAYWRIGHT } = testFrameworkTypes; const { ANGULAR, REACT, VUE, NO: CLIENT_FRAMEWORK_NO } = clientFrameworkTypes; const microfrontendsToPromptValue = (answer: string | { baseName: string }[]) => @@ -164,9 +164,12 @@ const command = { when: answers => [ANGULAR, REACT, VUE].includes(answers.clientFramework ?? config.clientFramework), type: 'checkbox', message: 'Besides Jest/Vitest, which testing frameworks would you like to use?', - default: () => intersection([CYPRESS], config.testFrameworks), + default: () => intersection([CYPRESS, PLAYWRIGHT], config.testFrameworks), }), - choices: [{ name: 'Cypress', value: CYPRESS }], + choices: [ + { name: 'Cypress', value: CYPRESS }, + { name: 'Playwright', value: PLAYWRIGHT }, + ], scope: 'storage', }, withAdminUi: { diff --git a/generators/client/generator.ts b/generators/client/generator.ts index e0356e1ae66c..aeae8ad0242d 100644 --- a/generators/client/generator.ts +++ b/generators/client/generator.ts @@ -36,7 +36,7 @@ import type { } from './types.d.ts'; const { ANGULAR, NO: CLIENT_FRAMEWORK_NO } = clientFrameworkTypes; -const { CYPRESS } = testFrameworkTypes; +const { CYPRESS, PLAYWRIGHT } = testFrameworkTypes; export class ClientApplicationGenerator< Entity extends ClientEntity = ClientEntity, @@ -119,6 +119,9 @@ export default class ClientGenerator extends ClientApplicationGenerator { if (Array.isArray(testFrameworks) && testFrameworks.includes(CYPRESS)) { await this.composeWithJHipster('cypress'); } + if (Array.isArray(testFrameworks) && testFrameworks.includes(PLAYWRIGHT)) { + await this.composeWithJHipster('playwright'); + } }, }); } diff --git a/generators/playwright/command.ts b/generators/playwright/command.ts new file mode 100644 index 000000000000..46bc6ee6150b --- /dev/null +++ b/generators/playwright/command.ts @@ -0,0 +1,25 @@ +/** + * Copyright 2013-2026 the original author or authors from the JHipster project. + * + * This file is part of the JHipster project, see https://www.jhipster.tech/ + * for more information. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { JHipsterCommandDefinition } from '../../lib/command/index.ts'; + +const command = { + configs: {}, +} as const satisfies JHipsterCommandDefinition; + +export default command; diff --git a/generators/playwright/files.ts b/generators/playwright/files.ts new file mode 100644 index 000000000000..bfcb3f4428cc --- /dev/null +++ b/generators/playwright/files.ts @@ -0,0 +1,88 @@ +/** + * Copyright 2013-2026 the original author or authors from the JHipster project. + * + * This file is part of the JHipster project, see https://www.jhipster.tech/ + * for more information. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { asWriteFilesSection } from '../base-application/support/task-type-inference.ts'; +import { clientRootTemplatesBlock } from '../client/support/index.ts'; +import { CLIENT_TEST_SRC_DIR } from '../generator-constants.ts'; + +const PLAYWRIGHT_TEMPLATE_SOURCE_DIR = `${CLIENT_TEST_SRC_DIR}playwright/`; + +export const playwrightFiles = asWriteFilesSection({ + common: [ + { + templates: ['README.md.jhi.playwright'], + }, + clientRootTemplatesBlock({ + templates: ['playwright.config.ts'], + }), + ], + clientTestFw: [ + { + path: PLAYWRIGHT_TEMPLATE_SOURCE_DIR, + renameTo: (ctx, file) => `${ctx.playwrightDir}${file}`, + templates: [ + 'fixtures/integration-test.png', + 'e2e/administration/administration.spec.ts', + 'support/commands.ts', + 'support/entity.ts', + 'support/management.ts', + 'support/login.ts', + ], + }, + { + condition: generator => !generator.applicationTypeMicroservice, + path: PLAYWRIGHT_TEMPLATE_SOURCE_DIR, + renameTo: (ctx, file) => `${ctx.playwrightDir}${file}`, + templates: ['e2e/account/logout.spec.ts'], + }, + { + condition: generator => !generator.authenticationTypeOauth2, + path: PLAYWRIGHT_TEMPLATE_SOURCE_DIR, + renameTo: (ctx, file) => `${ctx.playwrightDir}${file}`, + templates: ['e2e/account/login-page.spec.ts'], + }, + { + condition: generator => Boolean(generator.generateUserManagement), + path: PLAYWRIGHT_TEMPLATE_SOURCE_DIR, + renameTo: (ctx, file) => `${ctx.playwrightDir}${file}`, + templates: [ + 'e2e/account/register-page.spec.ts', + 'e2e/account/settings-page.spec.ts', + 'e2e/account/password-page.spec.ts', + 'e2e/account/reset-password-page.spec.ts', + 'support/account.ts', + ], + }, + { + condition: generator => generator.authenticationTypeOauth2, + path: PLAYWRIGHT_TEMPLATE_SOURCE_DIR, + renameTo: (ctx, file) => `${ctx.playwrightDir}${file}`, + templates: ['support/oauth2.ts'], + }, + ], +}); + +export const playwrightEntityFiles = asWriteFilesSection({ + testsPlaywright: [ + { + path: PLAYWRIGHT_TEMPLATE_SOURCE_DIR, + renameTo: ctx => `${ctx.playwrightDir}e2e/entity/${ctx.entityFileName}.spec.ts`, + templates: ['e2e/entity/_entity_.spec.ts'], + }, + ], +}); diff --git a/generators/playwright/generator.spec.ts b/generators/playwright/generator.spec.ts new file mode 100644 index 000000000000..c73d6514952a --- /dev/null +++ b/generators/playwright/generator.spec.ts @@ -0,0 +1,147 @@ +/** + * Copyright 2013-2026 the original author or authors from the JHipster project. + * + * This file is part of the JHipster project, see https://www.jhipster.tech/ + * for more information. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { before, describe, expect, it } from 'esmocha'; +import { basename } from 'node:path'; + +import { clientFrameworkTypes, testFrameworkTypes } from '../../lib/jhipster/index.ts'; +import { AuthenticationTypeMatrix, defaultHelpers as helpers, extendMatrix, fromMatrix, runResult } from '../../lib/testing/index.ts'; +import type { ConfigAll } from '../../lib/types/command-all.ts'; +import { checkEnforcements, shouldSupportFeatures, testBlueprintSupport } from '../../test/support/index.ts'; + +import Generator from './generator.ts'; + +const { PLAYWRIGHT } = testFrameworkTypes; +const { ANGULAR, REACT, VUE } = clientFrameworkTypes; + +const generator = basename(import.meta.dirname); + +const e2eMatrix = extendMatrix( + fromMatrix({ + ...AuthenticationTypeMatrix, + }), + { + clientFramework: [ANGULAR, REACT, VUE], + withAdminUi: [false, true], + clientRootDir: [undefined, { value: 'clientRoot/' }, { value: '' }], + }, +); + +const e2eSamples = Object.fromEntries( + Object.entries(e2eMatrix).map(([name, sample]) => [ + name, + { + ...sample, + testFrameworks: [PLAYWRIGHT], + }, + ]), +); + +const entities = [ + { + name: 'EntityA', + changelogDate: '20220129025419', + }, +]; + +describe(`generator - ${generator}`, () => { + shouldSupportFeatures(Generator); + describe('blueprint support', () => testBlueprintSupport(generator)); + checkEnforcements({ client: true }, generator); + + it('samples matrix should match snapshot', () => { + expect(e2eSamples).toMatchSnapshot(); + }); + + Object.entries(e2eSamples).forEach(([name, sampleConfig]) => { + describe(name, () => { + before(async () => { + await helpers.runJHipster(generator).withJHipsterConfig(sampleConfig, entities); + }); + + it('should match generated files snapshot', () => { + expect(runResult.getStateSnapshot()).toMatchSnapshot(); + }); + + it('contains playwright testFramework', () => { + runResult.assertJsonFileContent('.yo-rc.json', { 'generator-jhipster': { testFrameworks: [PLAYWRIGHT] } }); + }); + + describe('withAdminUi', () => { + const { applicationType, withAdminUi, clientRootDir = '' } = sampleConfig; + const generateAdminUi = applicationType !== 'microservice' && withAdminUi; + + if (applicationType !== 'microservice') { + const adminUiRoutingTitle = generateAdminUi ? 'should generate admin routing' : 'should not generate admin routing'; + const playwrightAdminRoot = clientRootDir ? `${clientRootDir}test/` : 'src/test/javascript/'; + it(adminUiRoutingTitle, () => { + const assertion = (...args: [string, string | RegExp]) => + generateAdminUi ? runResult.assertFileContent(...args) : runResult.assertNoFileContent(...args); + + assertion( + `${playwrightAdminRoot}playwright/e2e/administration/administration.spec.ts`, + ' metricsPageHeadingSelector,\n' + + ' healthPageHeadingSelector,\n' + + ' logsPageHeadingSelector,\n' + + ' configurationPageHeadingSelector,', + ); + + assertion( + `${playwrightAdminRoot}playwright/e2e/administration/administration.spec.ts`, + " test.describe('/metrics', () => {\n" + + " test('should load the page', async ({ page }) => {\n" + + " await clickOnAdminMenuItem(page, 'metrics');\n" + + ' await expect(page.locator(metricsPageHeadingSelector)).toBeVisible();\n' + + ' });\n' + + ' });\n' + + '\n' + + " test.describe('/health', () => {\n" + + " test('should load the page', async ({ page }) => {\n" + + " await clickOnAdminMenuItem(page, 'health');\n" + + ' await expect(page.locator(healthPageHeadingSelector)).toBeVisible();\n' + + ' });\n' + + ' });\n' + + '\n' + + " test.describe('/logs', () => {\n" + + " test('should load the page', async ({ page }) => {\n" + + " await clickOnAdminMenuItem(page, 'logs');\n" + + ' await expect(page.locator(logsPageHeadingSelector)).toBeVisible();\n' + + ' });\n' + + ' });\n' + + '\n' + + " test.describe('/configuration', () => {\n" + + " test('should load the page', async ({ page }) => {\n" + + " await clickOnAdminMenuItem(page, 'configuration');\n" + + ' await expect(page.locator(configurationPageHeadingSelector)).toBeVisible();\n' + + ' });\n' + + ' });', + ); + + assertion( + `${playwrightAdminRoot}playwright/support/commands.ts`, + 'export const metricsPageHeadingSelector = \'[data-cy="metricsPageHeading"]\';\n' + + 'export const healthPageHeadingSelector = \'[data-cy="healthPageHeading"]\';\n' + + 'export const logsPageHeadingSelector = \'[data-cy="logsPageHeading"]\';\n' + + 'export const configurationPageHeadingSelector = \'[data-cy="configurationPageHeading"]\';', + ); + }); + } + }); + }); + }); +}); diff --git a/generators/playwright/generator.ts b/generators/playwright/generator.ts new file mode 100644 index 000000000000..3f6a8b1fffc4 --- /dev/null +++ b/generators/playwright/generator.ts @@ -0,0 +1,186 @@ +/** + * Copyright 2013-2026 the original author or authors from the JHipster project. + * + * This file is part of the JHipster project, see https://www.jhipster.tech/ + * for more information. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mutateData, stringHashCode } from '../../lib/utils/index.ts'; +import BaseApplicationGenerator from '../base-application/index.ts'; +import { createFaker } from '../base-application/support/index.ts'; +import { generateTestEntity } from '../client/support/index.ts'; +import type { Source as JavaSource } from '../java/types.d.ts'; + +import { playwrightEntityFiles, playwrightFiles } from './files.ts'; +import type { + Application as PlaywrightApplication, + Config as PlaywrightConfig, + Entity as PlaywrightEntity, + Field as PlaywrightField, +} from './types.ts'; + +const WAIT_TIMEOUT = 3 * 60000; + +export default class PlaywrightGenerator extends BaseApplicationGenerator { + async beforeQueue() { + if (!this.fromBlueprint) { + await this.composeWithBlueprints(); + } + + if (!this.delegateToBlueprint) { + await this.dependsOnBootstrap('client'); + } + } + + get preparing() { + return this.asPreparingTaskGroup({ + loadPackageJson({ application }) { + this.loadNodeDependenciesFromPackageJson( + application.nodeDependencies, + this.fetchFromInstalledJHipster('client', 'resources', 'package.json'), + ); + }, + prepareForTemplates({ applicationDefaults }) { + applicationDefaults({ + playwrightDir: ({ clientTestDir }) => (clientTestDir ? `${clientTestDir}playwright/` : 'playwright/'), + playwrightTemporaryDir: ({ temporaryDir }) => (temporaryDir ? `${temporaryDir}playwright/` : '.playwright/'), + playwrightBootstrapEntities: true, + }); + }, + npmScripts({ application }) { + const { devServerPort, devServerPortProxy: devServerPortE2e = devServerPort } = application; + + Object.assign(application.clientPackageJsonScripts, { + playwright: 'playwright test --ui', + e2e: 'npm run e2e:playwright:headed --', + 'e2e:playwright': 'playwright test', + 'e2e:playwright:headed': 'playwright test --headed', + 'e2e:headless': 'npm run e2e:playwright --', + }); + + // Scripts that handle server and client concurrently + Object.assign(application.packageJsonScripts, { + 'ci:e2e:run': 'concurrently -k -s first -n application,e2e -c red,blue npm:ci:e2e:server:start npm:e2e:headless', + 'ci:e2e:dev': `concurrently -k -s first -n application,e2e -c red,blue npm:app:start npm:e2e:headless`, + 'e2e:dev': `concurrently -k -s first -n application,e2e -c red,blue npm:app:start npm:e2e`, + 'e2e:devserver': `concurrently -k -s first -n backend,frontend,e2e -c red,yellow,blue npm:backend:start npm:start "wait-on -t ${WAIT_TIMEOUT} http-get://127.0.0.1:${devServerPortE2e} && npm run e2e:headless"`, + }); + + if (application.clientRootDir) { + Object.assign(application.packageJsonScripts, { + 'e2e:headless': `npm run -w ${application.clientRootDir} e2e:headless`, + }); + } else if (application.backendTypeJavaAny) { + Object.assign(application.clientPackageJsonScripts, { + 'pree2e:headless': 'npm run ci:server:await', + }); + } + }, + }); + } + + get [BaseApplicationGenerator.PREPARING]() { + return this.delegateTasksToBlueprint(() => this.preparing); + } + + get postPreparingEachEntity() { + return this.asPreparingEachEntityTaskGroup({ + prepareForTemplates({ application, entity }) { + mutateData(entity, { + workaroundEntityCannotBeEmpty: false, + workaroundInstantReactiveMariaDB: false, + generateEntityPlaywright: ({ builtInUserManagement, skipClient }) => + !skipClient || (builtInUserManagement && application.clientFrameworkAngular), + }); + }, + }); + } + + get [BaseApplicationGenerator.POST_PREPARING_EACH_ENTITY]() { + return this.delegateTasksToBlueprint(() => this.postPreparingEachEntity); + } + + get writing() { + return this.asWritingTaskGroup({ + async writeFiles({ application }) { + const faker = await createFaker(); + faker.seed(stringHashCode(application.baseName)); + const context = { ...application, faker }; + return this.writeFiles({ + sections: playwrightFiles, + context, + }); + }, + }); + } + + get [BaseApplicationGenerator.WRITING]() { + return this.delegateTasksToBlueprint(() => this.writing); + } + + get writingEntities() { + return this.asWritingEntitiesTaskGroup({ + async writePlaywrightEntityFiles({ application, entities }) { + for (const entity of entities.filter( + entity => entity.generateEntityPlaywright && !entity.embedded && !entity.builtInUser && !entity.entityClientModelOnly, + )) { + const context = { ...application, ...entity }; + await this.writeFiles({ + sections: playwrightEntityFiles, + context, + }); + } + }, + }); + } + + get [BaseApplicationGenerator.WRITING_ENTITIES]() { + return this.delegateTasksToBlueprint(() => this.writingEntities); + } + + get postWriting() { + return this.asPostWritingTaskGroup({ + packageJson({ application }) { + const clientPackageJson = this.createStorage(this.destinationPath(application.clientRootDir!, 'package.json')); + clientPackageJson.merge({ + devDependencies: { + '@playwright/test': application.nodeDependencies['@playwright/test'] ?? '^1.50.1', + }, + }); + }, + mavenProfile({ source }) { + (source as JavaSource).addMavenProfile?.({ + id: 'e2e', + content: ` + + ,e2e + + + e2e + + `, + }); + }, + }); + } + + get [BaseApplicationGenerator.POST_WRITING]() { + return this.delegateTasksToBlueprint(() => this.postWriting); + } + + generateTestEntity(fields: PlaywrightField[]) { + return generateTestEntity(fields); + } +} diff --git a/generators/playwright/index.ts b/generators/playwright/index.ts new file mode 100644 index 000000000000..f59dc4db34c4 --- /dev/null +++ b/generators/playwright/index.ts @@ -0,0 +1,21 @@ +/** + * Copyright 2013-2026 the original author or authors from the JHipster project. + * + * This file is part of the JHipster project, see https://www.jhipster.tech/ + * for more information. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { default } from './generator.ts'; +export { default as command } from './command.ts'; +export { playwrightEntityFiles, playwrightFiles } from './files.ts'; diff --git a/generators/playwright/templates/README.md.jhi.playwright.ejs b/generators/playwright/templates/README.md.jhi.playwright.ejs new file mode 100644 index 000000000000..1c510899577d --- /dev/null +++ b/generators/playwright/templates/README.md.jhi.playwright.ejs @@ -0,0 +1,39 @@ +<%# + Copyright 2013-2026 the original author or authors from the JHipster project. + + This file is part of the JHipster project, see https://www.jhipster.tech/ + for more information. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-%> +<%# + This is a fragment file, it will be merged into the root template if available. + EJS fragments will process % delimiter tags in the template and & delimiter tags during the merge process. +-%> +<&_ if (fragment.testingSection) { -&> +#### E2E tests + +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`) in a second one. + +Before running Playwright tests, it's possible to specify user credentials by overriding the `E2E_USERNAME` and `E2E_PASSWORD` environment variables. + +```bash +export E2E_USERNAME="" +export E2E_PASSWORD="" +``` + +<&_ } -&> +<&_ if (fragment.referenceSection) { -&> +- [Playwright](https://playwright.dev/) +<&_ } -&> diff --git a/generators/playwright/templates/playwright.config.ts.ejs b/generators/playwright/templates/playwright.config.ts.ejs new file mode 100644 index 000000000000..8a30b0b19c03 --- /dev/null +++ b/generators/playwright/templates/playwright.config.ts.ejs @@ -0,0 +1,41 @@ +<%# + Copyright 2013-2026 the original author or authors from the JHipster project. + + This file is part of the JHipster project, see https://www.jhipster.tech/ + for more information. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-%> +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: '<%= this.relativeDir(clientRootDir, playwrightDir) %>e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: 'html', + use: { + baseURL: 'http://localhost:<%= gatewayServerPort || serverPort %>/', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + viewport: { width: 1200, height: 720 }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + outputDir: '<%= this.relativeDir(clientRootDir, playwrightTemporaryDir) %>test-results', +}); diff --git a/generators/playwright/templates/src/test/javascript/playwright/e2e/account/login-page.spec.ts.ejs b/generators/playwright/templates/src/test/javascript/playwright/e2e/account/login-page.spec.ts.ejs new file mode 100644 index 000000000000..624cf803fad3 --- /dev/null +++ b/generators/playwright/templates/src/test/javascript/playwright/e2e/account/login-page.spec.ts.ejs @@ -0,0 +1,106 @@ +<%# + Copyright 2013-2026 the original author or authors from the JHipster project. + + This file is part of the JHipster project, see https://www.jhipster.tech/ + for more information. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-%> +import { test, expect } from '@playwright/test'; +import { + titleLoginSelector, + errorLoginSelector, + usernameLoginSelector, + passwordLoginSelector, + submitLoginSelector, + getCredentials, + clickOnLoginItem, +} from '../../support/commands'; + +test.describe('login page', () => { + let username: string; + let password: string; + + test.beforeAll(() => { + const credentials = getCredentials(); + username = credentials.username; + password = credentials.password; + }); + + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await clickOnLoginItem(page); + }); + + test('greets with signin', async ({ page }) => { + await expect(page.locator(titleLoginSelector)).toBeVisible(); + }); +<%_ if (clientFrameworkAngular) { _%> + + test('greets visiting /login directly', async ({ page }) => { + await page.goto('/login'); + await expect(page.locator(titleLoginSelector)).toBeVisible(); + }); +<%_ } _%> + + test('requires username', async ({ page }) => { + const authPromise = page.waitForResponse(resp => + resp.url().includes('/api/<% if (authenticationTypeSession) { %>authentication<% } else { %>authenticate<% } %>') && resp.request().method() === 'POST' + ); + await page.locator(passwordLoginSelector).fill('a-password'); + await page.locator(submitLoginSelector).click(); +<%_ if (!clientFrameworkReact) { _%> + const response = await authPromise; + expect(response.status()).toBe(<% if (authenticationTypeSession) { %>401<% } else { %>400<% } %>); +<%_ } _%> + await expect(page.locator(titleLoginSelector)).toBeVisible(); + }); + + test('requires password', async ({ page }) => { + const authPromise = page.waitForResponse(resp => + resp.url().includes('/api/<% if (authenticationTypeSession) { %>authentication<% } else { %>authenticate<% } %>') && resp.request().method() === 'POST' + ); + await page.locator(usernameLoginSelector).fill('a-login'); + await page.locator(submitLoginSelector).click(); +<%_ if (!clientFrameworkReact) { _%> + const response = await authPromise; + expect(response.status()).toBe(<% if (authenticationTypeSession) { %>401<% } else { %>400<% } %>); + await expect(page.locator(errorLoginSelector)).toBeVisible(); +<%_ } else { _%> + await expect(page.locator(titleLoginSelector)).toBeVisible(); +<%_ } _%> + }); + + test('errors when password is incorrect', async ({ page }) => { + const authPromise = page.waitForResponse(resp => + resp.url().includes('/api/<% if (authenticationTypeSession) { %>authentication<% } else { %>authenticate<% } %>') && resp.request().method() === 'POST' + ); + await page.locator(usernameLoginSelector).fill(username); + await page.locator(passwordLoginSelector).fill('bad-password'); + await page.locator(submitLoginSelector).click(); + const response = await authPromise; + expect(response.status()).toBe(401); + await expect(page.locator(errorLoginSelector)).toBeVisible(); + }); + + test('go to home page when successfully logs in', async ({ page }) => { + const authPromise = page.waitForResponse(resp => + resp.url().includes('/api/<% if (authenticationTypeSession) { %>authentication<% } else { %>authenticate<% } %>') && resp.request().method() === 'POST' + ); + await page.locator(usernameLoginSelector).fill(username); + await page.locator(passwordLoginSelector).fill(password); + await page.locator(submitLoginSelector).click(); + const response = await authPromise; + expect(response.status()).toBe(200); + }); +}); diff --git a/generators/playwright/templates/src/test/javascript/playwright/e2e/account/logout.spec.ts.ejs b/generators/playwright/templates/src/test/javascript/playwright/e2e/account/logout.spec.ts.ejs new file mode 100644 index 000000000000..9c9f0ec53761 --- /dev/null +++ b/generators/playwright/templates/src/test/javascript/playwright/e2e/account/logout.spec.ts.ejs @@ -0,0 +1,52 @@ +<%# + Copyright 2013-2026 the original author or authors from the JHipster project. + + This file is part of the JHipster project, see https://www.jhipster.tech/ + for more information. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-%> +import { test, expect } from '@playwright/test'; +import { + accountMenuSelector, + navbarSelector, + loginItemSelector, + getCredentials, + login, + clickOnLogoutItem, +} from '../../support/commands'; + +test.describe('logout', () => { + let username: string; + let password: string; + + test.beforeAll(() => { + const credentials = getCredentials(); + username = credentials.username; + password = credentials.password; + }); + + test('go to home page when successfully logs out', async ({ page }) => { + await login(page, username, password); + await page.goto('/'); + + await clickOnLogoutItem(page); + +<%_ if (authenticationUsesCsrf) { _%> + // Wait for the logout API call to complete + await page.waitForResponse(resp => resp.url().includes('/api/logout') && resp.request().method() === 'POST'); +<%_ } _%> + await page.locator(navbarSelector).locator(accountMenuSelector).click(); + await expect(page.locator(navbarSelector).locator(accountMenuSelector).locator(loginItemSelector)).toBeVisible(); + }); +}); diff --git a/generators/playwright/templates/src/test/javascript/playwright/e2e/account/password-page.spec.ts.ejs b/generators/playwright/templates/src/test/javascript/playwright/e2e/account/password-page.spec.ts.ejs new file mode 100644 index 000000000000..8f4f240c83d0 --- /dev/null +++ b/generators/playwright/templates/src/test/javascript/playwright/e2e/account/password-page.spec.ts.ejs @@ -0,0 +1,107 @@ +<%# + Copyright 2013-2026 the original author or authors from the JHipster project. + + This file is part of the JHipster project, see https://www.jhipster.tech/ + for more information. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-%> +import { test, expect } from '@playwright/test'; +import { + currentPasswordSelector, + newPasswordSelector, + confirmPasswordSelector, + submitPasswordSelector, + classInvalid, + classValid, + getCredentials, + login, + clickOnPasswordItem, +} from '../../support/commands'; + +test.describe('/account/password', () => { + let username: string; + let password: string; + + test.beforeAll(() => { + const credentials = getCredentials(); + username = credentials.username; + password = credentials.password; + }); + + test.beforeEach(async ({ page }) => { + await login(page, username, password); + await page.goto('/account/password'); + }); + + test('should be accessible through menu', async ({ page }) => { + await page.goto('/'); + await clickOnPasswordItem(page); + await expect(page).toHaveURL(/\/account\/password$/); + }); + + test('requires current password', async ({ page }) => { +<%_ if (clientFrameworkReact) { _%> + await page.locator(submitPasswordSelector).click(); +<%_ } _%> + await expect(page.locator(currentPasswordSelector)).toHaveClass(new RegExp(classInvalid)); + await page.locator(currentPasswordSelector).fill('wrong-current-password'); + await page.locator(currentPasswordSelector).blur(); + await expect(page.locator(currentPasswordSelector)).toHaveClass(new RegExp(classValid)); + }); + + test('requires new password', async ({ page }) => { +<%_ if (clientFrameworkReact) { _%> + await page.locator(submitPasswordSelector).click(); +<%_ } _%> + await expect(page.locator(newPasswordSelector)).toHaveClass(new RegExp(classInvalid)); + await page.locator(newPasswordSelector).fill('jhipster'); + await page.locator(newPasswordSelector).blur(); + await expect(page.locator(newPasswordSelector)).toHaveClass(new RegExp(classValid)); + }); + + test('requires confirm new password', async ({ page }) => { +<%_ if (clientFrameworkReact) { _%> + await page.locator(submitPasswordSelector).click(); +<%_ } _%> + await page.locator(newPasswordSelector).fill('jhipster'); + await expect(page.locator(confirmPasswordSelector)).toHaveClass(new RegExp(classInvalid)); + await page.locator(confirmPasswordSelector).fill('jhipster'); + await page.locator(confirmPasswordSelector).blur(); + await expect(page.locator(confirmPasswordSelector)).toHaveClass(new RegExp(classValid)); + }); + + test('should fail to update password when using incorrect current password', async ({ page }) => { + const passwordPromise = page.waitForResponse(resp => + resp.url().includes('/api/account/change-password') && resp.request().method() === 'POST' + ); + await page.locator(currentPasswordSelector).fill('wrong-current-password'); + await page.locator(newPasswordSelector).fill('jhipster'); + await page.locator(confirmPasswordSelector).fill('jhipster'); + await page.locator(submitPasswordSelector).click(); + const response = await passwordPromise; + expect(response.status()).toBe(400); + }); + + test('should be able to update password', async ({ page }) => { + const passwordPromise = page.waitForResponse(resp => + resp.url().includes('/api/account/change-password') && resp.request().method() === 'POST' + ); + await page.locator(currentPasswordSelector).fill(password); + await page.locator(newPasswordSelector).fill(password); + await page.locator(confirmPasswordSelector).fill(password); + await page.locator(submitPasswordSelector).click(); + const response = await passwordPromise; + expect(response.status()).toBe(200); + }); +}); diff --git a/generators/playwright/templates/src/test/javascript/playwright/e2e/account/register-page.spec.ts.ejs b/generators/playwright/templates/src/test/javascript/playwright/e2e/account/register-page.spec.ts.ejs new file mode 100644 index 000000000000..e741fb37e2b2 --- /dev/null +++ b/generators/playwright/templates/src/test/javascript/playwright/e2e/account/register-page.spec.ts.ejs @@ -0,0 +1,135 @@ +<%# + Copyright 2013-2026 the original author or authors from the JHipster project. + + This file is part of the JHipster project, see https://www.jhipster.tech/ + for more information. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-%> +import { test, expect } from '@playwright/test'; +import { + usernameRegisterSelector, + emailRegisterSelector, + firstPasswordRegisterSelector, + secondPasswordRegisterSelector, + submitRegisterSelector, + classInvalid, + classValid, + clickOnRegisterItem, +} from '../../support/commands'; + +<% const registerPage = clientFrameworkVue ? '/register' : '/account/register'; _%> +test.describe('<%= registerPage %>', () => { + test.beforeEach(async ({ page }) => { + await page.goto('<%= registerPage %>'); + }); + + test('should be accessible through menu', async ({ page }) => { + await page.goto('/'); + await clickOnRegisterItem(page); + await expect(page).toHaveURL(/<%= registerPage.replace(/\//g, '\\/') %>$/); + }); + + test('should load the register page', async ({ page }) => { + await expect(page.locator(submitRegisterSelector)).toBeVisible(); + }); + + test('requires username', async ({ page }) => { +<%_ if (clientFrameworkReact) { _%> + await page.locator(submitRegisterSelector).click(); +<%_ } _%> + await expect(page.locator(usernameRegisterSelector)).toHaveClass(new RegExp(classInvalid)); + await page.locator(usernameRegisterSelector).fill('test'); + await page.locator(usernameRegisterSelector).blur(); + await expect(page.locator(usernameRegisterSelector)).toHaveClass(new RegExp(classValid)); + }); + + test('should not accept invalid email', async ({ page }) => { +<%_ if (clientFrameworkReact) { _%> + await page.locator(submitRegisterSelector).click(); +<%_ } _%> + await expect(page.locator(emailRegisterSelector)).toHaveClass(new RegExp(classInvalid)); + await page.locator(emailRegisterSelector).fill('testtest.fr'); + await page.locator(emailRegisterSelector).blur(); + await expect(page.locator(emailRegisterSelector)).toHaveClass(new RegExp(classInvalid)); + }); + + test('requires email in correct format', async ({ page }) => { +<%_ if (clientFrameworkReact) { _%> + await page.locator(submitRegisterSelector).click(); +<%_ } _%> + await expect(page.locator(emailRegisterSelector)).toHaveClass(new RegExp(classInvalid)); + await page.locator(emailRegisterSelector).fill('test@test.fr'); + await page.locator(emailRegisterSelector).blur(); + await expect(page.locator(emailRegisterSelector)).toHaveClass(new RegExp(classValid)); + }); + + test('requires first password', async ({ page }) => { +<%_ if (clientFrameworkReact) { _%> + await page.locator(submitRegisterSelector).click(); +<%_ } _%> + await expect(page.locator(firstPasswordRegisterSelector)).toHaveClass(new RegExp(classInvalid)); + await page.locator(firstPasswordRegisterSelector).fill('test@test.fr'); + await page.locator(firstPasswordRegisterSelector).blur(); + await expect(page.locator(firstPasswordRegisterSelector)).toHaveClass(new RegExp(classValid)); + }); + + test('requires password and confirm password to be same', async ({ page }) => { +<%_ if (clientFrameworkReact) { _%> + await page.locator(submitRegisterSelector).click(); +<%_ } _%> + await expect(page.locator(firstPasswordRegisterSelector)).toHaveClass(new RegExp(classInvalid)); + await page.locator(firstPasswordRegisterSelector).fill('test'); + await page.locator(firstPasswordRegisterSelector).blur(); + await expect(page.locator(firstPasswordRegisterSelector)).toHaveClass(new RegExp(classValid)); + await expect(page.locator(secondPasswordRegisterSelector)).toHaveClass(new RegExp(classInvalid)); + await page.locator(secondPasswordRegisterSelector).fill('test'); + await page.locator(secondPasswordRegisterSelector).blur(); + await expect(page.locator(secondPasswordRegisterSelector)).toHaveClass(new RegExp(classValid)); + }); + + test('requires password and confirm password have not the same value', async ({ page }) => { +<%_ if (clientFrameworkReact) { _%> + await page.locator(submitRegisterSelector).click(); +<%_ } _%> + await expect(page.locator(firstPasswordRegisterSelector)).toHaveClass(new RegExp(classInvalid)); + await page.locator(firstPasswordRegisterSelector).fill('test'); + await page.locator(firstPasswordRegisterSelector).blur(); + await expect(page.locator(firstPasswordRegisterSelector)).toHaveClass(new RegExp(classValid)); +<%_ if (clientFrameworkAngular) { _%> + await expect(page.locator(secondPasswordRegisterSelector)).toHaveClass(new RegExp(classInvalid)); + await page.locator(secondPasswordRegisterSelector).fill('otherPassword'); + await expect(page.locator(submitRegisterSelector)).toBeDisabled(); +<%_ } else { _%> + await expect(page.locator(secondPasswordRegisterSelector)).toHaveClass(new RegExp(classInvalid)); + await page.locator(secondPasswordRegisterSelector).fill('otherPassword'); + await page.locator(secondPasswordRegisterSelector).blur(); + await expect(page.locator(secondPasswordRegisterSelector)).toHaveClass(new RegExp(classInvalid)); +<%_ } _%> + }); + + test('register a valid user', async ({ page }) => { + const registerPromise = page.waitForResponse(resp => + resp.url().includes('/api/register') && resp.request().method() === 'POST' + ); + const randomEmail = '<%= faker.internet.email() %>'; + const randomUsername = '<%= faker.internet.username() %>'; + await page.locator(usernameRegisterSelector).fill(randomUsername); + await page.locator(emailRegisterSelector).fill(randomEmail); + await page.locator(firstPasswordRegisterSelector).fill('jondoe'); + await page.locator(secondPasswordRegisterSelector).fill('jondoe'); + await page.locator(submitRegisterSelector).click(); + const response = await registerPromise; + expect(response.status()).toBe(201); + }); +}); diff --git a/generators/playwright/templates/src/test/javascript/playwright/e2e/account/reset-password-page.spec.ts.ejs b/generators/playwright/templates/src/test/javascript/playwright/e2e/account/reset-password-page.spec.ts.ejs new file mode 100644 index 000000000000..8fc5f93fa6e1 --- /dev/null +++ b/generators/playwright/templates/src/test/javascript/playwright/e2e/account/reset-password-page.spec.ts.ejs @@ -0,0 +1,67 @@ +<%# + Copyright 2013-2026 the original author or authors from the JHipster project. + + This file is part of the JHipster project, see https://www.jhipster.tech/ + for more information. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-%> +import { test, expect } from '@playwright/test'; +import { + usernameLoginSelector, + forgetYourPasswordSelector, + emailResetPasswordSelector, + submitInitResetPasswordSelector, + classInvalid, + classValid, + getCredentials, + clickOnLoginItem, +} from '../../support/commands'; + +test.describe('forgot your password', () => { + let username: string; + + test.beforeAll(() => { + const credentials = getCredentials(); + username = credentials.username; + }); + + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await clickOnLoginItem(page); + await page.locator(usernameLoginSelector).fill(username); + await page.locator(forgetYourPasswordSelector).click(); + }); + + test('requires email', async ({ page }) => { +<%_ if (clientFrameworkReact) { _%> + await page.locator(submitInitResetPasswordSelector).click({ force: true }); +<%_ } _%> + await expect(page.locator(emailResetPasswordSelector)).toHaveClass(new RegExp(classInvalid)); + await page.locator(emailResetPasswordSelector).fill('user@gmail.com'); +<%_ if (clientFrameworkReact) { _%> + await page.locator(submitInitResetPasswordSelector).click({ force: true }); +<%_ } _%> + await expect(page.locator(emailResetPasswordSelector)).toHaveClass(new RegExp(classValid)); + }); + + test('should be able to init reset password', async ({ page }) => { + const resetPromise = page.waitForResponse(resp => + resp.url().includes('/api/account/reset-password/init') && resp.request().method() === 'POST' + ); + await page.locator(emailResetPasswordSelector).fill('user@gmail.com'); + await page.locator(submitInitResetPasswordSelector).click({ force: true }); + const response = await resetPromise; + expect(response.status()).toBe(200); + }); +}); diff --git a/generators/playwright/templates/src/test/javascript/playwright/e2e/account/settings-page.spec.ts.ejs b/generators/playwright/templates/src/test/javascript/playwright/e2e/account/settings-page.spec.ts.ejs new file mode 100644 index 000000000000..1481735f5b5d --- /dev/null +++ b/generators/playwright/templates/src/test/javascript/playwright/e2e/account/settings-page.spec.ts.ejs @@ -0,0 +1,159 @@ +<%# + Copyright 2013-2026 the original author or authors from the JHipster project. + + This file is part of the JHipster project, see https://www.jhipster.tech/ + for more information. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-%> +import { test, expect } from '@playwright/test'; +import { + firstNameSettingsSelector, + lastNameSettingsSelector, + submitSettingsSelector, + emailSettingsSelector, + getCredentials, + login, + clickOnSettingsItem, +} from '../../support/commands'; +import type { Account } from '../../support/account'; +import { getAccount, saveAccount } from '../../support/account'; + +test.describe('/account/settings', () => { + let adminUsername: string; + let adminPassword: string; + let username: string; + let password: string; + + const testUserEmail = 'user@localhost.fr'; + let originalUserAccount: Account; + let testUserAccount: Account; + + test.beforeAll(() => { + const credentials = getCredentials(); + adminUsername = credentials.adminUsername; + adminPassword = credentials.adminPassword; + username = credentials.username; + password = credentials.password; + }); + + test.beforeEach(async ({ page }) => { + await login(page, username, password); + + // On first run, fetch the original account and set up test email + if (!originalUserAccount) { + originalUserAccount = await getAccount(page); + testUserAccount = { ...originalUserAccount, email: testUserEmail }; + const status = await saveAccount(page, testUserAccount); + expect(status).toBe(200); + } + + await page.goto('/account/settings'); + await expect(page.locator(emailSettingsSelector)).toHaveValue(testUserEmail); + }); + + test.afterEach(async ({ page }) => { + await login(page, username, password); + const status = await saveAccount(page, testUserAccount); + expect(status).toBe(200); + }); + + test.afterAll(async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto('/'); + await login(page, username, password); + const status = await saveAccount(page, originalUserAccount); + expect(status).toBe(200); + await context.close(); + }); + + test('should be accessible through menu', async ({ page }) => { + await page.goto('/'); + await clickOnSettingsItem(page); + await expect(page).toHaveURL(/\/account\/settings$/); + }); + + test("should be able to change 'user' firstname settings", async ({ page }) => { + const savePromise = page.waitForResponse(resp => + resp.url().includes('/api/account') && resp.request().method() === 'POST' + ); + await page.locator(firstNameSettingsSelector).clear(); + await page.locator(firstNameSettingsSelector).fill('jhipster'); + await page.locator(submitSettingsSelector).click(); + const response = await savePromise; + expect(response.status()).toBe(200); + }); + + test("should be able to change 'user' lastname settings", async ({ page }) => { + const savePromise = page.waitForResponse(resp => + resp.url().includes('/api/account') && resp.request().method() === 'POST' + ); + await page.locator(lastNameSettingsSelector).clear(); + await page.locator(firstNameSettingsSelector).fill('jhipster'); + await page.locator(lastNameSettingsSelector).fill('retspihj'); + await page.locator(submitSettingsSelector).click(); + const response = await savePromise; + expect(response.status()).toBe(200); + }); + + test("should be able to change 'user' email settings", async ({ page }) => { + const savePromise = page.waitForResponse(resp => + resp.url().includes('/api/account') && resp.request().method() === 'POST' + ); + await page.locator(emailSettingsSelector).clear(); + await page.locator(firstNameSettingsSelector).fill('jhipster'); + await page.locator(emailSettingsSelector).fill('user@localhost.fr'); + await page.locator(submitSettingsSelector).click(); + const response = await savePromise; + expect(response.status()).toBe(200); + }); + + test.describe('if there is another user with an email', () => { + let originalAdminAccount: Account; + const testAdminEmail = 'admin@localhost.fr'; + + test.beforeAll(async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto('/'); + await login(page, adminUsername, adminPassword); + originalAdminAccount = await getAccount(page); + const status = await saveAccount(page, { ...originalAdminAccount, email: testAdminEmail }); + expect(status).toBe(200); + await context.close(); + }); + + test.afterAll(async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto('/'); + await login(page, adminUsername, adminPassword); + const status = await saveAccount(page, originalAdminAccount); + expect(status).toBe(200); + await context.close(); + }); + + test("should not be able to change 'user' email to same value", async ({ page }) => { + const savePromise = page.waitForResponse(resp => + resp.url().includes('/api/account') && resp.request().method() === 'POST' + ); + await page.locator(emailSettingsSelector).clear(); + await page.locator(firstNameSettingsSelector).fill('jhipster'); + await page.locator(emailSettingsSelector).fill(testAdminEmail); + await page.locator(submitSettingsSelector).click(); + const response = await savePromise; + expect(response.status()).toBe(400); + }); + }); +}); diff --git a/generators/playwright/templates/src/test/javascript/playwright/e2e/administration/administration.spec.ts.ejs b/generators/playwright/templates/src/test/javascript/playwright/e2e/administration/administration.spec.ts.ejs new file mode 100644 index 000000000000..a4300a96e931 --- /dev/null +++ b/generators/playwright/templates/src/test/javascript/playwright/e2e/administration/administration.spec.ts.ejs @@ -0,0 +1,120 @@ +<%# + Copyright 2013-2026 the original author or authors from the JHipster project. + + This file is part of the JHipster project, see https://www.jhipster.tech/ + for more information. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-%> +import { test, expect } from '@playwright/test'; +import { +<%_ if (generateUserManagement && !userManagement.generateEntityPlaywright) { _%> + userManagementPageHeadingSelector, +<%_ } _%> +<%_ if (withAdminUi) { _%> + metricsPageHeadingSelector, + healthPageHeadingSelector, + logsPageHeadingSelector, + configurationPageHeadingSelector, +<%_ } _%> + swaggerPageSelector, + swaggerFrameSelector, + getCredentials, + login, + clickOnAdminMenuItem, +} from '../../support/commands'; +import { getManagementInfo } from '../../support/management'; + +test.describe('/admin', () => { + let adminUsername: string; + let adminPassword: string; + + test.beforeAll(() => { + const credentials = getCredentials(); + adminUsername = credentials.adminUsername; + adminPassword = credentials.adminPassword; + }); + + test.beforeEach(async ({ page }) => { + await login(page, adminUsername, adminPassword); + await page.goto('/'); + }); + +<%_ if (generateUserManagement && !userManagement.generateEntityPlaywright) { _%> + test.describe('/user-management', () => { + test('should load the page', async ({ page }) => { + await clickOnAdminMenuItem(page, 'user-management'); + await expect(page.locator(userManagementPageHeadingSelector)).toBeVisible(); + }); + }); +<%_ } _%> + +<%_ if (withAdminUi) { _%> + test.describe('/metrics', () => { + test('should load the page', async ({ page }) => { + await clickOnAdminMenuItem(page, 'metrics'); + await expect(page.locator(metricsPageHeadingSelector)).toBeVisible(); + }); + }); + + test.describe('/health', () => { + test('should load the page', async ({ page }) => { + await clickOnAdminMenuItem(page, 'health'); + await expect(page.locator(healthPageHeadingSelector)).toBeVisible(); + }); + }); + + test.describe('/logs', () => { + test('should load the page', async ({ page }) => { + await clickOnAdminMenuItem(page, 'logs'); + await expect(page.locator(logsPageHeadingSelector)).toBeVisible(); + }); + }); + + test.describe('/configuration', () => { + test('should load the page', async ({ page }) => { + await clickOnAdminMenuItem(page, 'configuration'); + await expect(page.locator(configurationPageHeadingSelector)).toBeVisible(); + }); + }); +<%_ } _%> + + test.describe('/docs', () => { + test('should load the page', async ({ page }) => { + const info = await getManagementInfo(page); + if (info.activeProfiles.includes('api-docs')) { + await clickOnAdminMenuItem(page, 'docs'); + const swaggerFrame = page.locator(swaggerFrameSelector); + await expect(swaggerFrame).toBeVisible(); + + // Wait for iframe content to load + const frameLocator = page.frameLocator(swaggerFrameSelector); + await expect(frameLocator.locator(swaggerPageSelector)).toBeVisible({ timeout: 15000 }); + const optionCount = await frameLocator.locator('[id="select"] > option').count(); + expect(optionCount).toBeGreaterThan(0); + await expect(frameLocator.locator(swaggerPageSelector).locator('.information-container')).toBeVisible(); + } + }); + }); +<%_ if (communicationSpringWebsocket) { _%> + + test.describe('/tracker', () => { + test('should load the page', async ({ page }) => { + await clickOnAdminMenuItem(page, 'tracker'); + await expect(page.locator('[data-cy="trackerPageHeading"]')).toBeVisible(); + const rowCount = await page.locator('[data-cy="trackerTable"] > tbody > tr').count(); + expect(rowCount).toBe(1); + }); + }); +<%_ } _%> +}); diff --git a/generators/playwright/templates/src/test/javascript/playwright/e2e/entity/_entity_.spec.ts.ejs b/generators/playwright/templates/src/test/javascript/playwright/e2e/entity/_entity_.spec.ts.ejs new file mode 100644 index 000000000000..1bcfba03585e --- /dev/null +++ b/generators/playwright/templates/src/test/javascript/playwright/e2e/entity/_entity_.spec.ts.ejs @@ -0,0 +1,367 @@ +import { test, expect } from '@playwright/test'; +import { + entityTableSelector, + entityDetailsButtonSelector, + entityDetailsBackButtonSelector, +<%_ if (!readOnly) { _%> + entityCreateButtonSelector, + entityCreateSaveButtonSelector, + entityCreateCancelButtonSelector, + <%_ if (updatableEntity) { _%> + entityEditButtonSelector, + <%_ } _%> + entityDeleteButtonSelector, + entityConfirmDeleteButtonSelector, +<%_ } _%> + getEntityHeading, + getEntityCreateUpdateHeading, + getEntityDetailsHeading, + getEntityDeleteDialogHeading, +} from '../../support/entity'; +import { + getCredentials, + login, + authenticatedRequest, + clickOn<%= builtInUserManagement && userManagement.skipClient && userManagement.generateEntityPlaywright ? 'Admin' : 'Entity' %>MenuItem, +} from '../../support/commands'; +<%_ + +const customUserManagementPages = builtInUserManagement && userManagement.skipClient && userManagement.generateEntityPlaywright; +const entitiesUserManagementPages = builtInUserManagement && !userManagement.skipClient; +const baseApi = entityApi + 'api/'; + +const entityFakeData = generateFakeData('cypress'); +const requiredRelationships = relationships.filter(rel => rel.relationshipRequired || rel.id); +const requiredOtherEntities = this._.uniq(requiredRelationships.map(rel => rel.otherEntity)); +const otherEntities = this._.uniq(Object.values(differentRelationships).filter(rels => rels.length > 0).map(rels => rels[0].otherEntity)); +const skipCreateTest = + ( + !playwrightBootstrapEntities || + requiredRelationships.some(rel => rel.otherEntity.primaryKey && rel.otherEntity.primaryKey.derived) || + requiredRelationships.some(rel => rel.otherEntity.builtInUser || rel.otherEntity === this.entity) || + requiredRelationships.map(rel => rel.otherEntity.relationships).flat().some(rel => rel.relationshipRequired) || + !entityFakeData + ) ? '.skip' : ''; + +const sampleFields = fields.filter(f => !f.autoGenerate && !f.nullable); + +if (workaroundEntityCannotBeEmpty && sampleFields.length === 0) { + const sample = fields.find(f => !f.autoGenerate); + if (sample) { + sampleFields.push(sample); + } +} else if (workaroundInstantReactiveMariaDB) { + const samples = fields.filter(f => !f.autoGenerate && f.nullable && f.fieldType === 'Instant'); + if (samples.length > 0) { + sampleFields.push(...samples); + } +} +_%> + +test.describe('<%- entityNameCapitalized %> e2e test', () => { + const <%= entityInstance %>PageUrl = '<%= customUserManagementPages ? '/admin' : '' %>/<%= entityPage %>'; + const <%= entityInstance %>PageUrlPattern = new RegExp('/<%= entityPage %>(\\?.*)?$'); + let username: string; + let password: string; +<%_ if (!readOnly) { _%> + <% if (skipCreateTest) { %>// <% } %>const <%= entityInstance %>Sample = <%- JSON.stringify(this.generateTestEntity(sampleFields)) %>; +<%_ } _%> + + let <%= entityInstance %>: any; +<%_ for (otherEntity of requiredOtherEntities) { _%> + <% if (skipCreateTest) { %>// <% } %>let <%= otherEntity.entityInstance %>: any; +<%_ } _%> + + test.beforeAll(() => { + const credentials = getCredentials(); + <%- adminEntity ? 'username = credentials.adminUsername;\n password = credentials.adminPassword;' : 'username = credentials.username;\n password = credentials.password;' %> + }); + + test.beforeEach(async ({ page }) => { + await login(page, username, password); + }); + +<%_ if (requiredOtherEntities.length > 0) { _%> + <%_ if (skipCreateTest) { _%> + /* Disabled due to incompatibility + <%_ } _%> + test.beforeEach(async ({ page }) => { + const request = await authenticatedRequest(page); + <%_ for (otherEntity of requiredOtherEntities) { _%> + const <%= otherEntity.entityInstance %>Response = await request.post('/<%= baseApi + otherEntity.entityApiUrl %>', { + data: <%- JSON.stringify(this.generateTestEntity(otherEntity.fields.filter(f => !f.autoGenerate))) %>, + }); + <%= otherEntity.entityInstance %> = await <%= otherEntity.entityInstance %>Response.json(); + <%_ } _%> + }); + <%_ if (skipCreateTest) { _%> + */ + <%_ } _%> + +<%_ } _%> + test.afterEach(async ({ page }) => { + if (<%= entityInstance %>) { + const request = await authenticatedRequest(page); + await request.delete(`/<%= baseApi + entityApiUrl %>/${<%= entityInstance %>.<%= primaryKey.name %>}`); + <%= entityInstance %> = undefined; + } + }); + +<%_ if (requiredOtherEntities.length > 0) { _%> + <%_ if (skipCreateTest) { _%> + /* Disabled due to incompatibility + <%_ } _%> + test.afterEach(async ({ page }) => { + const request = await authenticatedRequest(page); + <%_ for (otherEntity of requiredOtherEntities) { _%> + if (<%= otherEntity.entityInstance %>) { + await request.delete(`/<%= baseApi + otherEntity.entityApiUrl %>/${<%= otherEntity.entityInstance %>.<%= otherEntity.primaryKey.name %>}`); + <%= otherEntity.entityInstance %> = undefined; + } + <%_ } _%> + }); + <%_ if (skipCreateTest) { _%> + */ + <%_ } _%> + +<%_ } _%> + + test('<%- entityNamePlural %> menu should load <%- entityNamePlural %> page', async ({ page }) => { + await page.goto('/'); + await clickOn<%= customUserManagementPages ? 'Admin' : 'Entity' %>MenuItem(page, '<%= entityPage %>'); + const entitiesResponse = await page.waitForResponse(resp => + resp.url().includes('/<%= baseApi + entityApiUrl %>') && resp.request().method() === 'GET' + ); + const body = await entitiesResponse.json(); + if (Array.isArray(body) && body.length === 0) { + await expect(page.locator(entityTableSelector)).not.toBeVisible(); + } else { + await expect(page.locator(entityTableSelector)).toBeVisible(); + } + await expect(getEntityHeading(page, '<%- entityNameCapitalized %>')).toBeVisible(); + await expect(page).toHaveURL(<%= entityInstance %>PageUrlPattern); + }); + + test.describe('<%- entityNameCapitalized %> page', () => { +<%_ if (!readOnly) { _%> + test.describe('create button click', () => { + test.beforeEach(async ({ page }) => { + await page.goto(<%= entityInstance %>PageUrl); + await page.waitForResponse(resp => + resp.url().includes('/<%= baseApi + entityApiUrl %>') && resp.request().method() === 'GET' + ); + }); + + test('should load create <%- entityNameCapitalized %> page', async ({ page }) => { + await page.locator(entityCreateButtonSelector).click(); + await expect(page).toHaveURL(new RegExp('/<%= entityPage %>/new$')); + await expect(getEntityCreateUpdateHeading(page, '<%- entityNameCapitalized %>')).toBeVisible(); + await expect(page.locator(entityCreateSaveButtonSelector)).toBeVisible(); + await page.locator(entityCreateCancelButtonSelector).click(); + const entitiesResponse = await page.waitForResponse(resp => + resp.url().includes('/<%= baseApi + entityApiUrl %>') && resp.request().method() === 'GET' + ); + expect(entitiesResponse.status()).toBe(200); + await expect(page).toHaveURL(<%= entityInstance %>PageUrlPattern); + }); + }); + +<%_ } _%> + test.describe('with existing value', () => { +<%_ if (!readOnly) { _%> + <%_ if (skipCreateTest) { _%> + /* Disabled due to incompatibility + <%_ } _%> + test.beforeEach(async ({ page }) => { + const request = await authenticatedRequest(page); + const createResponse = await request.post('/<%= baseApi + entityApiUrl %>', { + <%_ if (requiredRelationships.length > 0) { _%> + data: { + ...<%= entityInstance %>Sample, + <%_ for (relationship of requiredRelationships) { _%> + <%= relationship.propertyName %>: <%= relationship.collection ? '[' : '' %><%= relationship.otherEntity.entityInstance %><%= relationship.collection ? ']' : '' %>, + <%_ } _%> + }, + <%_ } else { _%> + data: <%= entityInstance %>Sample, + <%_ } _%> + }); + <%= entityInstance %> = await createResponse.json(); + + // Intercept the entities list request and return our entity + await page.route(new RegExp('/<%= baseApi + entityApiUrl %>(\\?.*)?$'), async route => { + await route.fulfill({ + status: 200, + <%_ if (!paginationNo) { _%> + headers: { + link: '?page=0&size=20>; rel="last",?page=0&size=20>; rel="first"', + 'content-type': 'application/json', + }, + <%_ } else { _%> + headers: { 'content-type': 'application/json' }, + <%_ } _%> + body: JSON.stringify([<%= entityInstance %>]), + }); + }, { times: 1 }); + + await page.goto(<%= entityInstance %>PageUrl); + }); + <%_ if (skipCreateTest) { _%> + */ + + <%_ } _%> +<%_ } _%> +<%_ if (readOnly || skipCreateTest) { _%> + test.beforeEach(async ({ page }) => { + await page.goto(<%= entityInstance %>PageUrl); + + const entitiesResponse = await page.waitForResponse(resp => + resp.url().includes('/<%= baseApi + entityApiUrl %>') && resp.request().method() === 'GET' + ); + const body = await entitiesResponse.json(); + if (Array.isArray(body) && body.length === 0) { + test.skip(); + } + }); +<%_ } _%> + + test('detail button click should load details <%- entityNameCapitalized %> page', async ({ page }) => { + await page.locator(entityDetailsButtonSelector).first().click(); + await expect(getEntityDetailsHeading(page, '<%= entityInstance %>')).toBeVisible(); + await page.locator(entityDetailsBackButtonSelector).click(); + const entitiesResponse = await page.waitForResponse(resp => + resp.url().includes('/<%= baseApi + entityApiUrl %>') && resp.request().method() === 'GET' + ); + expect(entitiesResponse.status()).toBe(200); + await expect(page).toHaveURL(<%= entityInstance %>PageUrlPattern); + }); + +<%_ if (!readOnly && updatableEntity) { _%> + test('edit button click should load edit <%- entityNameCapitalized %> page and go back', async ({ page }) => { + await page.locator(entityEditButtonSelector).first().click(); + await expect(getEntityCreateUpdateHeading(page, '<%- entityNameCapitalized %>')).toBeVisible(); + await expect(page.locator(entityCreateSaveButtonSelector)).toBeVisible(); + await page.locator(entityCreateCancelButtonSelector).click(); + const entitiesResponse = await page.waitForResponse(resp => + resp.url().includes('/<%= baseApi + entityApiUrl %>') && resp.request().method() === 'GET' + ); + expect(entitiesResponse.status()).toBe(200); + await expect(page).toHaveURL(<%= entityInstance %>PageUrlPattern); + }); + + <% if (jpaMetamodelFiltering && clientFrameworkVue && requiredRelationships.length) { %>// Reason: jpaMetamodelFiltering with required relationship misbehavior<% } %> + test<% if (jpaMetamodelFiltering && clientFrameworkVue && requiredRelationships.length) { %>.skip<% } %>('edit button click should load edit <%- entityNameCapitalized %> page and save', async ({ page }) => { + await page.locator(entityEditButtonSelector).first().click(); + await expect(getEntityCreateUpdateHeading(page, '<%- entityNameCapitalized %>')).toBeVisible(); + await page.locator(entityCreateSaveButtonSelector).click(); + const entitiesResponse = await page.waitForResponse(resp => + resp.url().includes('/<%= baseApi + entityApiUrl %>') && resp.request().method() === 'GET' + ); + expect(entitiesResponse.status()).toBe(200); + await expect(page).toHaveURL(<%= entityInstance %>PageUrlPattern); + }); + +<%_ } _%> +<%_ if (!readOnly) { _%> + <% if (skipCreateTest) { %>// Reason: cannot create a required entity with relationship with required relationships.<% } %> + test<%= skipCreateTest %>('last delete button click should delete instance of <%- entityNameCapitalized %>', async ({ page }) => { +<%_ if (clientFrameworkReact) { _%> + const dialogPromise = page.waitForResponse(resp => + resp.url().includes('/<%= baseApi + entityApiUrl %>/') && resp.request().method() === 'GET' + ); +<%_ } _%> + await page.locator(entityDeleteButtonSelector).last().click(); +<%_ if (clientFrameworkReact) { _%> + await dialogPromise; +<%_ } _%> + await expect(getEntityDeleteDialogHeading(page, '<%= entityInstance %>')).toBeVisible(); + await page.locator(entityConfirmDeleteButtonSelector).click(); + const deleteResponse = await page.waitForResponse(resp => + resp.url().includes('/<%= baseApi + entityApiUrl %>/') && resp.request().method() === 'DELETE' + ); + expect(deleteResponse.status()).toBe(204); + const entitiesResponse = await page.waitForResponse(resp => + resp.url().includes('/<%= baseApi + entityApiUrl %>') && resp.request().method() === 'GET' + ); + expect(entitiesResponse.status()).toBe(200); + await expect(page).toHaveURL(<%= entityInstance %>PageUrlPattern); + <% if (playwrightBootstrapEntities) { %> + <%= entityInstance %> = undefined; + <%_ } _%> + }); +<%_ } _%> + }); + }); +<%_ if (!readOnly) { _%> + + test.describe<% if (entitiesUserManagementPages) { %>.skip<% } %>('new <%- entityNameCapitalized %> page', () => { + test.beforeEach(async ({ page }) => { + await page.goto(<%= entityInstance %>PageUrl); + await page.locator(entityCreateButtonSelector).click(); + await expect(getEntityCreateUpdateHeading(page, '<%- entityNameCapitalized %>')).toBeVisible(); + }); + + <% if (skipCreateTest) { %>// Reason: cannot create a required entity with relationship with required relationships.<% } %> + test<%= skipCreateTest %>('should create an instance of <%- entityNameCapitalized %>', async ({ page }) => { + <%_ fields.filter(field => (!field.id || !field.autoGenerate) && !field.hidden && !field.readonly).forEach((field) => { + const fieldName = field.fieldName; + const fieldIsEnum = field.fieldIsEnum; + let fieldValue = !entityFakeData ? field.generateFakeData('cypress') : entityFakeData[field.fieldName]; + if (customUserManagementPages && (fieldName === 'langKey' || fieldName === 'activated')) { + return; + } + if (fieldValue === undefined) { + return; + } + fieldValue = typeof fieldValue === 'string' ? fieldValue.replaceAll("\\", "\\\\").replaceAll("'", "\\'") : fieldValue; + _%> + + <%_ if (field.fieldTypeBoolean) { _%> + await expect(page.locator(`[data-cy="<%= fieldName %>"]`)).not.toBeChecked(); + await page.locator(`[data-cy="<%= fieldName %>"]`).check(); + await expect(page.locator(`[data-cy="<%= fieldName %>"]`)).toBeChecked(); + + <%_ } else if (field.fieldTypeBinary && !field.blobContentTypeText) { _%> + const { setFieldImageAsBytesOfEntity } = await import('../../support/entity'); + await setFieldImageAsBytesOfEntity(page, '<%= fieldName %>', 'integration-test.png', 'image/png'); + + <%_ } else if (fieldIsEnum) { _%> + await page.locator(`[data-cy="<%= fieldName %>"]`).selectOption('<%- fieldValue %>'); + + <%_ } else if (field.fieldTypeString || field.fieldTypeNumeric || field.fieldTypeLocalDate || field.fieldTypeTimed || field.fieldTypeDuration) { _%> + await page.locator(`[data-cy="<%= fieldName %>"]`).fill('<%- fieldValue %>'); + <% if (field.fieldTypeLocalDate || field.fieldTypeTimed || field.fieldTypeDuration) { %>await page.locator(`[data-cy="<%= fieldName %>"]`).blur();<% } %> + await expect(page.locator(`[data-cy="<%= fieldName %>"]`)).toHaveValue('<%- fieldValue %>'); + + <%_ } else { _%> + await page.locator(`[data-cy="<%= fieldName %>"]`).fill('<%- fieldValue %>'); + await expect(page.locator(`[data-cy="<%= fieldName %>"]`)).toHaveValue(new RegExp('<%- fieldValue %>')); + + <%_ } _%> + <%_ }); _%> + <%_ for (relationship of requiredRelationships) { + _%> + await page.locator(`[data-cy="<%= relationship.relationshipFieldName %>"]`).selectOption(<%= relationship.collection ? '{ index: 0 }' : '{ index: 1 }' %>); + <%_ } _%> + + <%_ if (anyFieldHasFileBasedContentType) { _%> + // Wait briefly for blob field validation + await page.waitForTimeout(200); + <%_ } _%> + await page.locator(entityCreateSaveButtonSelector).click(); + + const postResponse = await page.waitForResponse(resp => + resp.url().includes('/<%= baseApi + entityApiUrl %>') && resp.request().method() === 'POST' + ); + expect(postResponse.status()).toBe(201); + <%= entityInstance %> = await postResponse.json(); + + const entitiesResponse = await page.waitForResponse(resp => + resp.url().includes('/<%= baseApi + entityApiUrl %>') && resp.request().method() === 'GET' + ); + expect(entitiesResponse.status()).toBe(200); + await expect(page).toHaveURL(<%= entityInstance %>PageUrlPattern); + }); + }); +<%_ } _%> +}); diff --git a/generators/playwright/templates/src/test/javascript/playwright/fixtures/integration-test.png b/generators/playwright/templates/src/test/javascript/playwright/fixtures/integration-test.png new file mode 100644 index 000000000000..5d31c2f84d84 Binary files /dev/null and b/generators/playwright/templates/src/test/javascript/playwright/fixtures/integration-test.png differ diff --git a/generators/playwright/templates/src/test/javascript/playwright/support/account.ts.ejs b/generators/playwright/templates/src/test/javascript/playwright/support/account.ts.ejs new file mode 100644 index 000000000000..483a7ee803b5 --- /dev/null +++ b/generators/playwright/templates/src/test/javascript/playwright/support/account.ts.ejs @@ -0,0 +1,41 @@ +<%# + Copyright 2013-2026 the original author or authors from the JHipster project. + + This file is part of the JHipster project, see https://www.jhipster.tech/ + for more information. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-%> +import type { Page } from '@playwright/test'; +import { authenticatedRequest } from './commands'; + +export type Account = Record; + +/** + * Retrieve the current user account via the API. + */ +export async function getAccount(page: Page): Promise { + const response = await page.context().request.get('/api/account'); + return response.json() as Promise; +} + +/** + * Save/update the current user account via the API. + */ +export async function saveAccount(page: Page, account: Account): Promise { + const request = await authenticatedRequest(page); + const response = await request.post('/api/account', { + data: account, + }); + return response.status(); +} diff --git a/generators/playwright/templates/src/test/javascript/playwright/support/commands.ts.ejs b/generators/playwright/templates/src/test/javascript/playwright/support/commands.ts.ejs new file mode 100644 index 000000000000..da09489f320a --- /dev/null +++ b/generators/playwright/templates/src/test/javascript/playwright/support/commands.ts.ejs @@ -0,0 +1,282 @@ +<%# + Copyright 2013-2026 the original author or authors from the JHipster project. + + This file is part of the JHipster project, see https://www.jhipster.tech/ + for more information. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-%> +import type { APIRequestContext, Page } from '@playwright/test'; + +// Navbar selectors +export const navbarSelector = '[data-cy="navbar"]'; +export const adminMenuSelector = '[data-cy="adminMenu"]'; +export const accountMenuSelector = '[data-cy="accountMenu"]'; +export const registerItemSelector = '[data-cy="register"]'; +export const settingsItemSelector = '[data-cy="settings"]'; +export const passwordItemSelector = '[data-cy="passwordItem"]'; +export const loginItemSelector = '[data-cy="login"]'; +export const logoutItemSelector = '[data-cy="logout"]'; +export const entityItemSelector = '[data-cy="entity"]'; + +<%_ if (!authenticationTypeOauth2) { _%> +// Login +export const titleLoginSelector = '[data-cy="loginTitle"]'; +export const errorLoginSelector = '[data-cy="loginError"]'; +export const usernameLoginSelector = '[data-cy="username"]'; +export const passwordLoginSelector = '[data-cy="password"]'; +export const forgetYourPasswordSelector = '[data-cy="forgetYourPasswordSelector"]'; +export const submitLoginSelector = '[data-cy="submit"]'; + +// Register +export const usernameRegisterSelector = '[data-cy="username"]'; +export const emailRegisterSelector = '[data-cy="email"]'; +export const firstPasswordRegisterSelector = '[data-cy="firstPassword"]'; +export const secondPasswordRegisterSelector = '[data-cy="secondPassword"]'; +export const submitRegisterSelector = '[data-cy="submit"]'; + +// Settings +export const firstNameSettingsSelector = '[data-cy="firstname"]'; +export const lastNameSettingsSelector = '[data-cy="lastname"]'; +export const emailSettingsSelector = '[data-cy="email"]'; +export const submitSettingsSelector = '[data-cy="submit"]'; + +// Password +export const currentPasswordSelector = '[data-cy="currentPassword"]'; +export const newPasswordSelector = '[data-cy="newPassword"]'; +export const confirmPasswordSelector = '[data-cy="confirmPassword"]'; +export const submitPasswordSelector = '[data-cy="submit"]'; + +// Reset Password +export const emailResetPasswordSelector = '[data-cy="emailResetPassword"]'; +export const submitInitResetPasswordSelector = '[data-cy="submit"]'; +<%_ } _%> + +// Administration +<%_ if (generateUserManagement && !userManagement.generateEntityPlaywright) { _%> +export const userManagementPageHeadingSelector = '[data-cy="<%- userManagement.entityNameCapitalized %>Heading"]'; +<%_ } _%> +export const swaggerFrameSelector = 'iframe[data-cy="swagger-frame"]'; +export const swaggerPageSelector = '[id="swagger-ui"]'; +<%_ if (withAdminUi) { _%> +export const metricsPageHeadingSelector = '[data-cy="metricsPageHeading"]'; +export const healthPageHeadingSelector = '[data-cy="healthPageHeading"]'; +export const logsPageHeadingSelector = '[data-cy="logsPageHeading"]'; +export const configurationPageHeadingSelector = '[data-cy="configurationPageHeading"]'; +<%_ } _%> + +export const classInvalid = <% if (clientFrameworkAngular) { %>'ng-invalid';<% } else { %>'is-invalid'<% } %>; +export const classValid = <% if (clientFrameworkAngular) { %>'ng-valid'<% } else { %>'is-valid'<% } %>; + +export interface Credentials { + adminUsername: string; + adminPassword: string; + username: string; + password: string; +} + +/** + * Return credentials from environment variables or defaults. + */ +export function getCredentials(): Credentials { + return { + adminUsername: process.env.E2E_USERNAME ?? '<%- defaultAdminUsername %>', + adminPassword: process.env.E2E_PASSWORD ?? '<%- defaultAdminPassword %>', + username: process.env.E2E_USERNAME ?? '<%- defaultUserUsername %>', + password: process.env.E2E_PASSWORD ?? '<%- defaultUserPassword %>', + }; +} + +<%_ if (authenticationTypeJwt) { _%> +/** + * Perform a JWT-based login via the API, then store the token in session storage + * so subsequent page navigations are authenticated. + */ +let jwtToken: string | undefined; + +export async function login(page: Page, username: string, password: string): Promise { + const context = page.context(); + // Warm up the session cookie / CSRF + await context.request.get('/api/account', { failOnStatusCode: false }); + + const response = await context.request.post('/api/authenticate', { + data: { username, password }, + }); + + const body = await response.json(); + const token = body.id_token; + jwtToken = token; + + // Inject the token into session storage before navigating + await context.addInitScript((tokenVal) => { + <%_ if (clientFrameworkVue) { _%> + sessionStorage.setItem('<%= jhiPrefixDashed %>-authenticationToken', tokenVal); + <%_ } else { _%> + sessionStorage.setItem('<%= jhiPrefixDashed %>-authenticationToken', JSON.stringify(tokenVal)); + <%_ } _%> + }, token); +} + +/** + * Create an authenticated API request context for direct API calls. + * Wraps the base request context to inject the Authorization Bearer header. + */ +export async function authenticatedRequest(page: Page): Promise { + const baseRequest = page.context().request; + if (!jwtToken) { + return baseRequest; + } + return new Proxy(baseRequest as any, { + get(target, prop, receiver) { + if (['get', 'post', 'put', 'patch', 'delete', 'head'].includes(prop as string)) { + return (url: any, options?: any) => { + const headers = { + ...(options?.headers ?? {}), + Authorization: `Bearer ${jwtToken}`, + }; + return target[prop as keyof typeof target](url, { ...options, headers }); + }; + } + if (prop === 'fetch') { + return (input: any, init?: any) => { + const headers = { + ...(init?.headers ?? {}), + Authorization: `Bearer ${jwtToken}`, + }; + return target[prop as keyof typeof target](input, { ...init, headers }); + }; + } + return Reflect.get(target, prop, receiver); + }, + }) as APIRequestContext; +} +<%_ } else if (authenticationTypeSession) { _%> +/** + * Perform a session-based login via the API. + */ +export async function login(page: Page, username: string, password: string): Promise { + const context = page.context(); + await context.request.get('/api/account', { failOnStatusCode: false }); + + // Retrieve XSRF token from cookies + const cookies = await context.cookies(); + const xsrfCookie = cookies.find(c => c.name === 'XSRF-TOKEN'); + + await context.request.post('/api/authentication', { + form: { username, password }, + headers: { + 'X-XSRF-TOKEN': xsrfCookie?.value ?? '', + }, + }); +} + +/** + * Create an authenticated API request that includes the XSRF token. + */ +export async function authenticatedRequest(page: Page): Promise { + const context = page.context(); + const baseRequest = context.request; + + // Retrieve XSRF token from cookies + const cookies = await context.cookies(); + const xsrfCookie = cookies.find(c => c.name === 'XSRF-TOKEN'); + const xsrfToken = xsrfCookie?.value; + + // If there is no XSRF token, fall back to the base request context. + if (!xsrfToken) { + return baseRequest; + } + + // Wrap the base request context to inject the XSRF header on state-changing requests. + const proxiedRequest = new Proxy(baseRequest as any, { + get(target, prop, receiver) { + if (prop === 'post' || prop === 'put' || prop === 'patch' || prop === 'delete') { + return (url: any, options?: any) => { + const headers = { + ...(options?.headers ?? {}), + 'X-XSRF-TOKEN': xsrfToken, + }; + return target[prop](url, { ...options, headers }); + }; + } + + if (prop === 'fetch') { + return (input: any, init?: any) => { + const headers = { + ...(init?.headers ?? {}), + 'X-XSRF-TOKEN': xsrfToken, + }; + return target[prop](input, { ...init, headers }); + }; + } + + return Reflect.get(target, prop, receiver); + }, + }); + + return proxiedRequest as APIRequestContext; +} +<%_ } else if (authenticationTypeOauth2) { _%> +/** + * Perform an OAuth2 login. + * Delegates to provider-specific helpers based on the redirect URL. + */ +export async function login(page: Page, username: string, password: string): Promise { + const { oauthLogin } = await import('./oauth2'); + await oauthLogin(page, username, password); +} + +/** + * Create an authenticated API request context. + */ +export async function authenticatedRequest(page: Page): Promise { + return page.context().request; +} +<%_ } _%> + +// Navbar helpers + +export async function clickOnLoginItem(page: Page): Promise { + await page.locator(navbarSelector).locator(accountMenuSelector).click(); + await page.locator(navbarSelector).locator(accountMenuSelector).locator(loginItemSelector).click(); +} + +export async function clickOnLogoutItem(page: Page): Promise { + await page.locator(navbarSelector).locator(accountMenuSelector).click(); + await page.locator(navbarSelector).locator(accountMenuSelector).locator(logoutItemSelector).click(); +} + +export async function clickOnRegisterItem(page: Page): Promise { + await page.locator(navbarSelector).locator(accountMenuSelector).click(); + await page.locator(navbarSelector).locator(accountMenuSelector).locator(registerItemSelector).click(); +} + +export async function clickOnSettingsItem(page: Page): Promise { + await page.locator(navbarSelector).locator(accountMenuSelector).click(); + await page.locator(navbarSelector).locator(accountMenuSelector).locator(settingsItemSelector).click(); +} + +export async function clickOnPasswordItem(page: Page): Promise { + await page.locator(navbarSelector).locator(accountMenuSelector).click(); + await page.locator(navbarSelector).locator(accountMenuSelector).locator(passwordItemSelector).click(); +} + +export async function clickOnAdminMenuItem(page: Page, item: string): Promise { + await page.locator(navbarSelector).locator(adminMenuSelector).click(); + await page.locator(navbarSelector).locator(adminMenuSelector).locator(`.dropdown-item[href="/admin/${item}"]`).click(); +} + +export async function clickOnEntityMenuItem(page: Page, entityName: string): Promise { + await page.locator(navbarSelector).locator(entityItemSelector).click(); + await page.locator(navbarSelector).locator(entityItemSelector).locator(`.dropdown-item[href="/${entityName}"]`).click(<% if(clientFrameworkAngular) { %>{ force: true }<% } %>); +} diff --git a/generators/playwright/templates/src/test/javascript/playwright/support/entity.ts.ejs b/generators/playwright/templates/src/test/javascript/playwright/support/entity.ts.ejs new file mode 100644 index 000000000000..261c23c99f3c --- /dev/null +++ b/generators/playwright/templates/src/test/javascript/playwright/support/entity.ts.ejs @@ -0,0 +1,81 @@ +<%# + Copyright 2013-2026 the original author or authors from the JHipster project. + + This file is part of the JHipster project, see https://www.jhipster.tech/ + for more information. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-%> +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Page } from '@playwright/test'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Entity selectors +export const entityTableSelector = '[data-cy="entityTable"]'; +export const entityCreateButtonSelector = '[data-cy="entityCreateButton"]'; +export const entityCreateSaveButtonSelector = '[data-cy="entityCreateSaveButton"]'; +export const entityCreateCancelButtonSelector = '[data-cy="entityCreateCancelButton"]'; +export const entityDetailsButtonSelector = '[data-cy="entityDetailsButton"]'; +export const entityDetailsBackButtonSelector = '[data-cy="entityDetailsBackButton"]'; +export const entityEditButtonSelector = '[data-cy="entityEditButton"]'; +export const entityDeleteButtonSelector = '[data-cy="entityDeleteButton"]'; +export const entityConfirmDeleteButtonSelector = '[data-cy="entityConfirmDeleteButton"]'; + +export function getEntityHeading(page: Page, entityName: string) { + return page.locator(`[data-cy="${entityName}Heading"]`); +} + +export function getEntityCreateUpdateHeading(page: Page, entityName: string) { + return page.locator(`[data-cy="${entityName}CreateUpdateHeading"]`); +} + +export function getEntityDetailsHeading(page: Page, entityInstanceName: string) { + return page.locator(`[data-cy="${entityInstanceName}DetailsHeading"]`); +} + +export function getEntityDeleteDialogHeading(page: Page, entityInstanceName: string) { + return page.locator(`[data-cy="${entityInstanceName}DeleteDialogHeading"]`); +} + +/** + * Set an image (from the fixtures directory) as the value of a file input field. + */ +export async function setFieldImageAsBytesOfEntity(page: Page, fieldName: string, fileName: string, mimeType: string): Promise { + const fixturePath = resolve(__dirname, '..', 'fixtures', fileName); + const fileInput = page.locator(`[data-cy="${fieldName}"]`); + await fileInput.setInputFiles({ + name: fileName, + mimeType, + buffer: readFileSync(fixturePath), + }); +} + +/** + * Select the last option in a select element. + */ +export async function setFieldSelectToLastOfEntity(page: Page, fieldName: string): Promise { + const select = page.locator(`[data-cy="${fieldName}"]`); + const options = select.locator('option'); + const count = await options.count(); + if (count > 0) { + const lastOption = options.nth(count - 1); + const value = await lastOption.getAttribute('value'); + if (value !== null) { + await select.selectOption(value); + } + } +} diff --git a/generators/playwright/templates/src/test/javascript/playwright/support/login.ts.ejs b/generators/playwright/templates/src/test/javascript/playwright/support/login.ts.ejs new file mode 100644 index 000000000000..4be56736fb64 --- /dev/null +++ b/generators/playwright/templates/src/test/javascript/playwright/support/login.ts.ejs @@ -0,0 +1,23 @@ +<%# + Copyright 2013-2026 the original author or authors from the JHipster project. + + This file is part of the JHipster project, see https://www.jhipster.tech/ + for more information. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-%> +/** + * Re-exports the login function from commands for convenience. + * Tests can import from either module. + */ +export { login, getCredentials } from './commands'; diff --git a/generators/playwright/templates/src/test/javascript/playwright/support/management.ts.ejs b/generators/playwright/templates/src/test/javascript/playwright/support/management.ts.ejs new file mode 100644 index 000000000000..af7e76b3ca92 --- /dev/null +++ b/generators/playwright/templates/src/test/javascript/playwright/support/management.ts.ejs @@ -0,0 +1,27 @@ +<%# + Copyright 2013-2026 the original author or authors from the JHipster project. + + This file is part of the JHipster project, see https://www.jhipster.tech/ + for more information. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-%> +import type { Page } from '@playwright/test'; + +/** + * Retrieve the management info endpoint data. + */ +export async function getManagementInfo(page: Page): Promise { + const response = await page.context().request.get('/management/info'); + return response.json(); +} diff --git a/generators/playwright/templates/src/test/javascript/playwright/support/oauth2.ts.ejs b/generators/playwright/templates/src/test/javascript/playwright/support/oauth2.ts.ejs new file mode 100644 index 000000000000..078a6fac5b59 --- /dev/null +++ b/generators/playwright/templates/src/test/javascript/playwright/support/oauth2.ts.ejs @@ -0,0 +1,152 @@ +<%# + Copyright 2013-2026 the original author or authors from the JHipster project. + + This file is part of the JHipster project, see https://www.jhipster.tech/ + for more information. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-%> +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +/** + * Initiate the OAuth2 login flow and return the authorization redirect URL. + */ +async function getOauth2RedirectUrl(page: Page): Promise { + const response = await page.context().request.get('/oauth2/authorization/oidc', { + maxRedirects: 0, + failOnStatusCode: false, + }); + return response.headers()['location']; +} + +/** + * Perform an OAuth2 login, detecting the provider from the redirect URL. + */ +export async function oauthLogin(page: Page, username: string, password: string): Promise { + const redirectUrl = await getOauth2RedirectUrl(page); + const url = new URL(redirectUrl); + + if (url.origin.includes('okta')) { + await oktaLogin(page, redirectUrl, username, password); + } else if (url.origin.includes('auth0')) { + await auth0Login(page, redirectUrl, username, password); + } else { + await keycloakLogin(page, redirectUrl, username, password); + } +} + +/** + * Handle Keycloak OAuth2 login flow. + */ +async function keycloakLogin(page: Page, redirectUrl: string, username: string, password: string): Promise { + const request = page.context().request; + + // Follow the redirect to get the login form + const loginPageResponse = await request.get(redirectUrl, { maxRedirects: 0, failOnStatusCode: false }); + const html = await loginPageResponse.text(); + + // Parse the form action URL from the HTML + const formActionMatch = html.match(/action="([^"]+)"/); + if (!formActionMatch) { + throw new Error('Could not find login form action URL in Keycloak response'); + } + const formUrl = formActionMatch[1].replace(/&/g, '&'); + const resolvedFormUrl = new URL(formUrl, redirectUrl).toString(); + + // Submit credentials + await request.post(resolvedFormUrl, { + form: { username, password }, + maxRedirects: 0, + failOnStatusCode: false, + }); + + // Complete the OAuth2 flow + await request.get('/oauth2/authorization/oidc', { maxRedirects: 5 }); + await page.goto('/'); +} + +/** + * Handle Auth0 OAuth2 login flow. + */ +async function auth0Login(page: Page, redirectUrl: string, username: string, password: string): Promise { + const request = page.context().request; + const url = new URL(redirectUrl); + + // Follow the authorization URL + const loginPageResponse = await request.get(redirectUrl, { maxRedirects: 5 }); + const html = await loginPageResponse.text(); + + // Extract state parameter + const stateMatch = html.match(/name="state"\s+value="([^"]+)"/); + if (!stateMatch) { + throw new Error('Could not find state parameter in Auth0 response'); + } + + // Submit credentials + await request.post(`${url.origin}/u/login`, { + form: { + state: stateMatch[1], + action: 'default', + username, + password, + }, + maxRedirects: 5, + failOnStatusCode: false, + }); + + // Complete the flow + await request.get('/oauth2/authorization/oidc', { maxRedirects: 5 }); + await page.goto('/'); +} + +/** + * Handle Okta OAuth2 login flow. + */ +async function oktaLogin(page: Page, redirectUrl: string, username: string, password: string): Promise { + const request = page.context().request; + const url = new URL(redirectUrl); + + // Get session token from Okta authn API + const authnResponse = await request.post(`${url.origin}/api/v1/authn`, { + data: { username, password }, + maxRedirects: 0, + }); + const authnBody = await authnResponse.json(); + const sessionToken = authnBody.sessionToken; + + // Use the session token to complete the authorization + await request.get(`${redirectUrl}&sessionToken=${sessionToken}`, { maxRedirects: 5 }); + await page.goto('/'); +} + +/** + * Perform an OAuth2 logout. + */ +export async function oauthLogout(page: Page): Promise { + const request = page.context().request; + const cookies = await page.context().cookies(); + const xsrfCookie = cookies.find(c => c.name === 'XSRF-TOKEN'); + + const logoutResponse = await request.post('api/logout', { + headers: { + 'X-XSRF-TOKEN': xsrfCookie?.value ?? '', + origin: new URL(page.url()).origin, + }, + }); + expect(logoutResponse.status()).toBe(200); + + const logoutBody = await logoutResponse.json(); + await request.get(logoutBody.logoutUrl, { maxRedirects: 5 }); + await page.goto('/'); +} diff --git a/generators/playwright/types.d.ts b/generators/playwright/types.d.ts new file mode 100644 index 000000000000..df3b169ebfb2 --- /dev/null +++ b/generators/playwright/types.d.ts @@ -0,0 +1,29 @@ +import type { HandleCommandTypes } from '../../lib/command/index.ts'; +import type { + Application as JavascriptApplication, + Config as JavascriptConfig, + Entity as JavascriptEntity, + Options as JavascriptOptions, +} from '../client/types.ts'; + +export type { Field, Relationship } from '../client/types.ts'; +import type command from './command.ts'; + +type Command = HandleCommandTypes; + +export type Config = JavascriptConfig & Command['Config']; + +export type Options = JavascriptOptions & Command['Options']; + +export interface Entity extends JavascriptEntity { + workaroundEntityCannotBeEmpty?: boolean; + workaroundInstantReactiveMariaDB?: boolean; + generateEntityPlaywright?: boolean; +} + +export type Application = JavascriptApplication & + Command['Application'] & { + playwrightDir: string; + playwrightTemporaryDir: string; + playwrightBootstrapEntities: boolean; + }; diff --git a/lib/jhipster/application-options.ts b/lib/jhipster/application-options.ts index 2295b70018db..1513787c853a 100644 --- a/lib/jhipster/application-options.ts +++ b/lib/jhipster/application-options.ts @@ -45,7 +45,7 @@ const { CAFFEINE, EHCACHE, HAZELCAST, INFINISPAN, MEMCACHED, REDIS } = cacheType const NO_CACHE_PROVIDER = cacheTypes.NO; -const { CYPRESS, CUCUMBER, GATLING } = testFrameworkTypes; +const { CYPRESS, PLAYWRIGHT, CUCUMBER, GATLING } = testFrameworkTypes; const { ANGULAR, REACT, VUE, SVELTE, NO } = clientFrameworkTypes; const { ELASTICSEARCH } = searchEngineTypes; @@ -218,6 +218,7 @@ export const jhipsterOptionValues = { [optionNames.SKIP_USER_MANAGEMENT]: false, [optionNames.TEST_FRAMEWORKS]: { [CYPRESS]: CYPRESS, + [PLAYWRIGHT]: PLAYWRIGHT, [CUCUMBER]: CUCUMBER, [GATLING]: GATLING, }, diff --git a/lib/jhipster/test-framework-types.ts b/lib/jhipster/test-framework-types.ts index 55533753ebb3..4089c6116e10 100644 --- a/lib/jhipster/test-framework-types.ts +++ b/lib/jhipster/test-framework-types.ts @@ -19,6 +19,7 @@ const testFrameworkTypes = { CYPRESS: 'cypress', + PLAYWRIGHT: 'playwright', CUCUMBER: 'cucumber', GATLING: 'gatling', NO: 'no',