Skip to content

Commit

Permalink
Introduce htmx tests with Playwright
Browse files Browse the repository at this point in the history
  • Loading branch information
ia3andy committed Jan 17, 2024
1 parent 639d5a8 commit 22bf44d
Show file tree
Hide file tree
Showing 14 changed files with 368 additions and 4 deletions.
2 changes: 1 addition & 1 deletion docs/modules/ROOT/pages/includes/attributes.adoc
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+

Expand Down
6 changes: 6 additions & 0 deletions integration-tests/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@
<artifactId>quarkus-renarde-backoffice</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.quarkiverse.playwright</groupId>
<artifactId>quarkus-playwright</artifactId>
<version>0.0.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkiverse.renarde</groupId>
<artifactId>quarkus-renarde-oidc-tests</artifactId>
Expand Down
103 changes: 103 additions & 0 deletions integration-tests/src/main/java/rest/HtmxApp.java
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 integration-tests/src/main/java/service/ContactService.java
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);
}
}
}

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions integration-tests/src/main/resources/templates/HtmxApp/edit.html
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 integration-tests/src/main/resources/templates/HtmxApp/index.html
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 integration-tests/src/main/resources/templates/HtmxApp/view.html
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>
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>
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}/>
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+", " "));
}

}
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
<maven.compiler.release>11</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<quarkus.version>3.6.4</quarkus.version>
<quarkus.version>3.6.5</quarkus.version>
<openhtmltopdf.version>1.0.10</openhtmltopdf.version>
<zxing.version>3.5.1</zxing.version>
</properties>
Expand Down
Loading

0 comments on commit 22bf44d

Please sign in to comment.