-
Notifications
You must be signed in to change notification settings - Fork 22
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Introduce htmx tests with Playwright
- Loading branch information
Showing
14 changed files
with
368 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,4 @@ | ||
:quarkus-version: 3.6.4 | ||
:quarkus-version: 3.6.5 | ||
:quarkus-renarde-version: 3.0.7 | ||
:maven-version: 3.8.1+ | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
package rest; | ||
|
||
import java.util.Collection; | ||
|
||
import jakarta.inject.Inject; | ||
import jakarta.validation.constraints.Email; | ||
import jakarta.validation.constraints.NotBlank; | ||
import jakarta.validation.constraints.Pattern; | ||
import jakarta.ws.rs.DELETE; | ||
import jakarta.ws.rs.PUT; | ||
import jakarta.ws.rs.Path; | ||
|
||
import org.jboss.resteasy.reactive.RestForm; | ||
import org.jboss.resteasy.reactive.RestPath; | ||
|
||
import io.quarkiverse.renarde.htmx.HxController; | ||
import io.quarkus.qute.CheckedTemplate; | ||
import io.quarkus.qute.TemplateInstance; | ||
import service.ContactService; | ||
|
||
public class HtmxApp extends HxController { | ||
|
||
@Inject | ||
ContactService contactService; | ||
|
||
@CheckedTemplate | ||
static class Templates { | ||
|
||
public static native TemplateInstance index(Collection<ContactService.Contact> contacts); | ||
|
||
public static native TemplateInstance index$list(Collection<ContactService.Contact> contacts); | ||
|
||
public static native TemplateInstance view(ContactService.Contact contact); | ||
|
||
public static native TemplateInstance edit(ContactService.Contact contact); | ||
|
||
} | ||
|
||
@Path("") | ||
public TemplateInstance index() { | ||
if (isHxRequest()) { | ||
return Templates.index$list(contactService.contacts().values()); | ||
} | ||
return Templates.index(contactService.contacts().values()); | ||
} | ||
|
||
public TemplateInstance view(@RestPath("id") int id) { | ||
if (!contactService.contacts().containsKey(id)) { | ||
throw new IllegalArgumentException("Invalid id: " + id); | ||
} | ||
return Templates.view(contactService.contacts().get(id)); | ||
} | ||
|
||
public TemplateInstance edit(@RestPath("id") int id) { | ||
if (!contactService.contacts().containsKey(id)) { | ||
throw new IllegalArgumentException("Invalid id: " + id); | ||
} | ||
return Templates.edit(contactService.contacts().get(id)); | ||
} | ||
|
||
@PUT | ||
public void save(@RestPath int id, @RestForm @NotBlank @Pattern(regexp = "[A-Z][a-z]+") String firstName, | ||
@RestForm @NotBlank @Pattern(regexp = "[A-Z]+") String lastName, | ||
@RestForm @Email String email) { | ||
if (!contactService.contacts().containsKey(id)) { | ||
throw new IllegalArgumentException("Invalid id: " + id); | ||
} | ||
|
||
if (validationFailed()) { | ||
edit(id); | ||
} | ||
|
||
final ContactService.Contact contact = contactService.contacts().get(id); | ||
contact.firstName = firstName; | ||
contact.lastName = lastName; | ||
contact.email = email; | ||
view(id); | ||
} | ||
|
||
@PUT | ||
public void lock(@RestPath int id) { | ||
if (!contactService.contacts().containsKey(id)) { | ||
throw new IllegalArgumentException("Invalid id: " + id); | ||
} | ||
final ContactService.Contact contact = contactService.contacts().get(id); | ||
contact.locked = !contact.locked; | ||
if (contact.locked) { | ||
view(id); | ||
} else { | ||
edit(id); | ||
} | ||
} | ||
|
||
@DELETE | ||
public void delete(@RestPath int id) { | ||
if (!contactService.contacts().containsKey(id)) { | ||
throw new IllegalArgumentException("Invalid id: " + id); | ||
} | ||
contactService.contacts().remove(id); | ||
hx(HxResponseHeader.TRIGGER, "refreshList"); | ||
} | ||
|
||
} |
51 changes: 51 additions & 0 deletions
51
integration-tests/src/main/java/service/ContactService.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
package service; | ||
|
||
import java.util.HashMap; | ||
import java.util.Map; | ||
|
||
import jakarta.enterprise.context.ApplicationScoped; | ||
|
||
@ApplicationScoped | ||
public class ContactService { | ||
private static final Contact JOE = new Contact(6, "Joe", "BLOW", "[email protected]"); | ||
private static final Contact FOO = new Contact(7, "Foo", "BLOW", "[email protected]"); | ||
private static final Contact BAR = new Contact(10, "Bar", "AWESOME", "[email protected]"); | ||
|
||
private final Map<Integer, Contact> contacts = new HashMap<>(); | ||
|
||
public ContactService() { | ||
reset(); | ||
} | ||
|
||
public Map<Integer, Contact> contacts() { | ||
return contacts; | ||
} | ||
|
||
public void reset() { | ||
contacts.clear(); | ||
contacts.put(JOE.id, JOE.clone()); | ||
contacts.put(FOO.id, FOO.clone()); | ||
contacts.put(BAR.id, BAR.clone()); | ||
} | ||
|
||
public static class Contact { | ||
public final int id; | ||
public String firstName; | ||
public String lastName; | ||
public String email; | ||
|
||
public boolean locked = false; | ||
|
||
public Contact(int id, String firstName, String lastName, String email) { | ||
this.id = id; | ||
this.firstName = firstName; | ||
this.lastName = lastName; | ||
this.email = email; | ||
} | ||
|
||
@Override | ||
public Contact clone() { | ||
return new Contact(id, firstName, lastName, email); | ||
} | ||
} | ||
} |
1 change: 1 addition & 0 deletions
1
integration-tests/src/main/resources/META-INF/resources/htmx.js
Large diffs are not rendered by default.
Oops, something went wrong.
23 changes: 23 additions & 0 deletions
23
integration-tests/src/main/resources/templates/HtmxApp/edit.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
<div class="col" hx-target='this' hx-swap="outerHTML" aria-label="Editing {contact.firstName} {contact.lastName}"> | ||
{#form uri:HtmxApp.save(contact.id) hx-put=uri:HtmxApp.save(contact.id) style="display: contents;"} | ||
{#formElement name="firstName" label="First Name"} | ||
{#input name="firstName" value=contact.firstName readonly=contact.locked/} | ||
{/formElement} | ||
{#formElement name="lastName" label="Last Name"} | ||
{#input name="lastName" value=contact.lastName readonly=contact.locked/} | ||
{/formElement} | ||
{#formElement name="email" label="Email Address"} | ||
{#input name="email" type="email" value=contact.email readonly=contact.locked/} | ||
{/formElement} | ||
|
||
{#if !contact.locked} | ||
<button class="btn btn-primary" aria-label="Save">Save</button> | ||
{/if} | ||
<button class="btn btn-warning" hx-get="{uri:HtmxApp.view(contact.id)}" aria-label="Cancel">Cancel</button> | ||
{/form} | ||
{#if !contact.locked} | ||
<button class="btn btn-danger" hx-delete="{uri:HtmxApp.delete(contact.id)}" aria-label="Delete">Delete</button> | ||
{/if} | ||
<button class="btn btn-secondary" hx-put="{uri:HtmxApp.lock(contact.id)}" aria-label="{contact.locked ? 'Unlock' : 'Lock'}">{contact.locked ? 'Unlock' : 'Lock'}</button> | ||
|
||
</div> |
32 changes: 32 additions & 0 deletions
32
integration-tests/src/main/resources/templates/HtmxApp/index.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
<!DOCTYPE html> | ||
<html lang="en"> | ||
<head> | ||
<meta charset="UTF-8"> | ||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
<meta http-equiv="X-UA-Compatible" content="ie=edge"> | ||
<title>Htmx Powered</title> | ||
<link rel="icon" href="/favicon.ico" type="image/x-icon"> | ||
<script src="/htmx.js"></script> | ||
<link rel="stylesheet" media="screen" href="/webjars/bootstrap/css/bootstrap.min.css"> | ||
<style> | ||
input:read-only { | ||
background-color: #e9ecef; | ||
} | ||
</style> | ||
</head> | ||
<body hx-headers='{"{inject:csrf.headerName}":"{inject:csrf.token}"}'> | ||
<main class="container"> | ||
|
||
<h1>Htmx Powered</h1> | ||
<div hx-get="{uri:HtmxApp.index}" hx-trigger="refreshList from:body"> | ||
{#fragment id="list"} | ||
{#for contact in contacts} | ||
<div class="row mb-4"> | ||
{#include HtmxApp/view /} | ||
</div> | ||
{/for} | ||
{/fragment} | ||
</div> | ||
</main> | ||
</body> | ||
</html> |
10 changes: 10 additions & 0 deletions
10
integration-tests/src/main/resources/templates/HtmxApp/view.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
<div class="col" hx-target="this" hx-swap="outerHTML" aria-label="Viewing {contact.firstName} {contact.lastName}"> | ||
<div aria-label="Details"> | ||
<div><label>First Name</label>: {contact.firstName}</div> | ||
<div><label>Last Name</label>: {contact.lastName}</div> | ||
<div><label>Email</label>: {contact.email}</div> | ||
</div> | ||
<button hx-get="{uri:HtmxApp.edit(contact.id)}" class="btn btn-primary" aria-label="Edit"> | ||
Click To Edit | ||
</button> | ||
</div> |
7 changes: 7 additions & 0 deletions
7
integration-tests/src/main/resources/templates/tags/formElement.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
<div class="mb-3 form-group"> | ||
<label class="form-label" for="{name}">{label}</label> | ||
{nested-content} | ||
{#ifError name} | ||
<span class="invalid-feedback" aria-label="Error for {name}">{#error name/}</span> | ||
{/ifError} | ||
</div> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
<input type="{type ?: 'text'}" class="{class ?: 'form-control'} {#ifError name}is-invalid{/ifError}" {#if readonly}readonly ‹{/if}value="{inject:flash.get(name) ?: value}" {_args.skip('class', 'type', 'readonly').asHtmlAttributes.safe}/> |
130 changes: 130 additions & 0 deletions
130
integration-tests/src/test/java/io/quarkiverse/renarde/it/RenardeHtmxPlaywrightTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,130 @@ | ||
package io.quarkiverse.renarde.it; | ||
|
||
import java.net.URL; | ||
|
||
import jakarta.inject.Inject; | ||
|
||
import org.junit.jupiter.api.Assertions; | ||
import org.junit.jupiter.api.Test; | ||
|
||
import com.microsoft.playwright.BrowserContext; | ||
import com.microsoft.playwright.ElementHandle; | ||
import com.microsoft.playwright.Page; | ||
import com.microsoft.playwright.Response; | ||
|
||
import io.quarkiverse.playwright.InjectPlaywright; | ||
import io.quarkiverse.playwright.WithPlaywright; | ||
import io.quarkus.test.common.http.TestHTTPResource; | ||
import io.quarkus.test.junit.QuarkusTest; | ||
import service.ContactService; | ||
|
||
@QuarkusTest | ||
@WithPlaywright | ||
public class RenardeHtmxPlaywrightTest { | ||
|
||
@Inject | ||
ContactService contactService; | ||
|
||
@InjectPlaywright | ||
BrowserContext context; | ||
|
||
@TestHTTPResource("/HtmxApp") | ||
URL index; | ||
|
||
@Test | ||
public void testOpenAndEditJoeAndBar() { | ||
contactService.reset(); | ||
try (Page page = context.newPage()) { | ||
Response response = page.navigate(index.toString()); | ||
Assertions.assertEquals("OK", response.statusText()); | ||
|
||
page.waitForLoadState(); | ||
|
||
Assertions.assertEquals("Htmx Powered", page.title()); | ||
|
||
page.waitForSelector("[aria-label='Viewing Foo BLOW'] button[aria-label='Edit']").click(); | ||
final ElementHandle editingFooBlow = page.waitForSelector("[aria-label='Editing Foo BLOW']"); | ||
|
||
page.waitForSelector("[aria-label='Viewing Joe BLOW'] button[aria-label='Edit']").click(); | ||
final ElementHandle editingJoeBlow = page.waitForSelector("[aria-label='Editing Joe BLOW']"); | ||
|
||
editingFooBlow.waitForSelector("input[name='firstName']").fill("Foofoo"); | ||
editingFooBlow.waitForSelector("input[name='lastName']").fill("BLOWBLOW"); | ||
editingFooBlow.waitForSelector("button[aria-label='Save']").click(); | ||
|
||
editingJoeBlow.waitForSelector("input[name='firstName']").fill("Joejoe"); | ||
editingJoeBlow.waitForSelector("input[name='lastName']").fill("BLOWBLOW"); | ||
editingJoeBlow.waitForSelector("button[aria-label='Save']").click(); | ||
|
||
checkContact(page, new ContactService.Contact(6, "Joejoe", "BLOWBLOW", "[email protected]")); | ||
checkContact(page, new ContactService.Contact(7, "Foofoo", "BLOWBLOW", "[email protected]")); | ||
} | ||
} | ||
|
||
@Test | ||
public void testLockJoe() { | ||
contactService.reset(); | ||
try (Page page = context.newPage()) { | ||
Response response = page.navigate(index.toString()); | ||
Assertions.assertEquals("OK", response.statusText()); | ||
page.waitForLoadState(); | ||
|
||
page.waitForSelector("[aria-label='Viewing Joe BLOW'] button[aria-label='Edit']").click(); | ||
final ElementHandle editingJoeBlow = page.waitForSelector("[aria-label='Editing Joe BLOW']"); | ||
editingJoeBlow.waitForSelector("button[aria-label='Lock']").click(); | ||
page.waitForSelector("[aria-label='Viewing Joe BLOW'] button[aria-label='Edit']").click(); | ||
final ElementHandle editingJoeBlow2 = page.waitForSelector("[aria-label='Editing Joe BLOW']"); | ||
Assertions.assertFalse(editingJoeBlow2.waitForSelector("input[name='firstName']").isEditable()); | ||
Assertions.assertFalse(editingJoeBlow2.waitForSelector("input[name='lastName']").isEditable()); | ||
Assertions.assertFalse(editingJoeBlow2.waitForSelector("input[name='email']").isEditable()); | ||
editingJoeBlow2.waitForSelector("button[aria-label='Unlock']").click(); | ||
} | ||
} | ||
|
||
@Test | ||
public void testDeleteJoe() { | ||
contactService.reset(); | ||
try (Page page = context.newPage()) { | ||
Response response = page.navigate(index.toString()); | ||
Assertions.assertEquals("OK", response.statusText()); | ||
page.waitForLoadState(); | ||
|
||
page.waitForSelector("[aria-label='Viewing Joe BLOW'] button[aria-label='Edit']").click(); | ||
final ElementHandle editingJoeBlow = page.waitForSelector("[aria-label='Editing Joe BLOW']"); | ||
editingJoeBlow.waitForSelector("button[aria-label='Delete']").click(); | ||
Assertions.assertEquals(2, contactService.contacts().size()); | ||
page.waitForCondition(() -> page.querySelector("[aria-label='Viewing Joe BLOW']") == null); | ||
} | ||
} | ||
|
||
@Test | ||
public void testFormValidation() { | ||
contactService.reset(); | ||
try (Page page = context.newPage()) { | ||
Response response = page.navigate(index.toString()); | ||
Assertions.assertEquals("OK", response.statusText()); | ||
page.waitForLoadState(); | ||
|
||
page.waitForSelector("[aria-label='Viewing Joe BLOW'] button[aria-label='Edit']").click(); | ||
final ElementHandle editingJoeBlow = page.waitForSelector("[aria-label='Editing Joe BLOW']"); | ||
editingJoeBlow.waitForSelector("input[name='firstName']").fill(""); | ||
editingJoeBlow.waitForSelector("input[name='lastName']").fill("blow"); | ||
editingJoeBlow.waitForSelector("button[aria-label='Save']").click(); | ||
|
||
page.waitForSelector("[aria-label='Editing Joe BLOW'] input.is-invalid[name='firstName']"); | ||
page.waitForSelector("[aria-label='Editing Joe BLOW'] input.is-invalid[name='lastName']"); | ||
page.waitForSelector("[aria-label='Editing Joe BLOW'] [aria-label='Error for firstName']"); | ||
page.waitForSelector("[aria-label='Editing Joe BLOW'] [aria-label='Error for lastName']"); | ||
} | ||
} | ||
|
||
private static void checkContact(Page page, ContactService.Contact contact) { | ||
final String fooContent = page.waitForSelector( | ||
"[aria-label='Viewing " + contact.firstName + " " + contact.lastName + "'] [aria-label='Details']") | ||
.textContent(); | ||
Assertions.assertEquals( | ||
"First Name: " + contact.firstName + " Last Name: " + contact.lastName + " Email: " + contact.email, | ||
fooContent.strip().replaceAll("\\s+", " ")); | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.