diff --git a/.github/workflows/playwright-examples.yml b/.github/workflows/playwright-examples.yml index e2bd8ed1..1bdef9da 100644 --- a/.github/workflows/playwright-examples.yml +++ b/.github/workflows/playwright-examples.yml @@ -35,6 +35,9 @@ jobs: standalone: runs-on: ubuntu-latest + permissions: + checks: write + contents: read env: SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} @@ -51,13 +54,23 @@ jobs: timeout_minutes: 30 max_attempts: 3 command: cd ./playwright-examples && mvn test -Dtest=StandaloneTest -Dsurefire.parallel=1 + - name: Test Report + uses: dorny/test-reporter@v2 + if: success() || failure() + with: + name: Standalone Test Report + path: playwright-examples/target/surefire-reports/*.xml + reporter: java-junit suite: runs-on: ubuntu-latest + permissions: + checks: write + contents: read strategy: fail-fast: false matrix: - browser: [ Chrome, MicrosoftEdge ] + browser: [ chromium, firefox, webkit ] env: SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} @@ -74,3 +87,10 @@ jobs: timeout_minutes: 30 max_attempts: 3 command: cd ./playwright-examples && mvn test -Dsauce.browser.name=${{ matrix.browser }} + - name: Test Report + uses: dorny/test-reporter@v2 + if: success() || failure() + with: + name: Suite Test Report (${{ matrix.browser }}) + path: playwright-examples/target/surefire-reports/*.xml + reporter: java-junit diff --git a/playwright-examples/README.md b/playwright-examples/README.md index 2dcca171..6add49cf 100644 --- a/playwright-examples/README.md +++ b/playwright-examples/README.md @@ -1,7 +1,7 @@ # Playwright Examples -There is only one way to execute Playwright on Sauce Labs with Java. -It works with any production version of Chrome or Edge. +Tests connect to Sauce Labs through Playwright's native WebSocket endpoint, so any of +Playwright's engines - Chromium, Firefox or WebKit - works the same way. [Setup Instructions](https://github.com/saucelabs-training/demo-java/blob/main/README.md#%EF%B8%8Fsetupprerequisites) and @@ -34,22 +34,26 @@ the [Main README](https://github.com/saucelabs-training/demo-java/blob/main/READ ## Configurations -This code allows toggling which browser the tests will run on. +### Browser -### Chrome (default) - -Tests will execute on the latest version of Chrome: +This code allows toggling which browser engine the tests will run on: `chromium` (default), +`firefox`, or `webkit`. ``` - $ mvn clean test -Dsauce.browser=Chrome + $ mvn clean test -Dsauce.browser.name=firefox ``` -### Microsoft Edge +### Session grouping / parallelism + +By default, all the test methods in a test class share a single Sauce session (one Sauce job per +test class), and different test classes run in parallel with each other. A session that sees a +test failure is closed and reported immediately rather than reused by the next test. -Tests will execute on the latest version of Edge: +To give every test method its own independent Sauce session instead, switch JUnit5's own +method-execution mode to `concurrent`: ``` - $ mvn clean test -Dsauce.browser=MicrosoftEdge + $ mvn clean test -Djunit.parallel.mode.default=concurrent ``` ## Disclaimer diff --git a/playwright-examples/pom.xml b/playwright-examples/pom.xml index af79ac1b..b4c52b8b 100644 --- a/playwright-examples/pom.xml +++ b/playwright-examples/pom.xml @@ -11,6 +11,7 @@ 10 + same_thread UTF-8 21 21 @@ -29,28 +30,11 @@ 6.1.2 test - - com.saucelabs - sauce_bindings - 2.0.0-beta.1 - org.json json 20250517 - - org.projectlombok - lombok - 1.18.46 - provided - - - com.saucelabs - saucerest - 2.5.3 - test - @@ -66,7 +50,8 @@ junit.jupiter.execution.parallel.enabled = true - junit.jupiter.execution.parallel.mode.default = concurrent + junit.jupiter.execution.parallel.mode.default = ${junit.parallel.mode.default} + junit.jupiter.execution.parallel.mode.classes.default = concurrent junit.jupiter.execution.parallel.config.strategy = fixed junit.jupiter.execution.parallel.config.fixed.parallelism = ${surefire.parallel} junit.jupiter.execution.parallel.config.fixed.max-pool-size = ${surefire.parallel} diff --git a/playwright-examples/src/test/java/com/saucedemo/playwright/SortingTest.java b/playwright-examples/src/test/java/com/saucedemo/playwright/SortingTest.java new file mode 100644 index 00000000..efafb687 --- /dev/null +++ b/playwright-examples/src/test/java/com/saucedemo/playwright/SortingTest.java @@ -0,0 +1,67 @@ +package com.saucedemo.playwright; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class SortingTest extends TestBase { + + @Test + public void sortByNameAscending() { + login(); + page.locator("[data-test='product-sort-container']").selectOption("az"); + + List actual = itemNames(); + Assertions.assertEquals(sorted(actual, Comparator.naturalOrder()), actual); + } + + @Test + public void sortByNameDescending() { + login(); + page.locator("[data-test='product-sort-container']").selectOption("za"); + + List actual = itemNames(); + Assertions.assertEquals(sorted(actual, Comparator.reverseOrder()), actual); + } + + @Test + public void sortByPriceLowToHigh() { + login(); + page.locator("[data-test='product-sort-container']").selectOption("lohi"); + + List actual = itemPrices(); + Assertions.assertEquals(sorted(actual, Comparator.naturalOrder()), actual); + } + + @Test + public void sortByPriceHighToLow() { + login(); + page.locator("[data-test='product-sort-container']").selectOption("hilo"); + + List actual = itemPrices(); + Assertions.assertEquals(sorted(actual, Comparator.reverseOrder()), actual); + } + + private void login() { + page.navigate("https://www.saucedemo.com/"); + page.locator("[data-test='username']").fill("standard_user"); + page.locator("[data-test='password']").fill("secret_sauce"); + page.locator("[data-test='login-button']").click(); + } + + private List itemNames() { + return page.locator(".inventory_item_name").allTextContents(); + } + + private List itemPrices() { + return page.locator(".inventory_item_price").allTextContents().stream() + .map(price -> Double.parseDouble(price.replace("$", ""))) + .collect(Collectors.toList()); + } + + private List sorted(List values, Comparator comparator) { + return values.stream().sorted(comparator).collect(Collectors.toList()); + } +} diff --git a/playwright-examples/src/test/java/com/saucedemo/playwright/TestBase.java b/playwright-examples/src/test/java/com/saucedemo/playwright/TestBase.java index 2f36a7fb..fbf6a0dc 100644 --- a/playwright-examples/src/test/java/com/saucedemo/playwright/TestBase.java +++ b/playwright-examples/src/test/java/com/saucedemo/playwright/TestBase.java @@ -1,26 +1,20 @@ package com.saucedemo.playwright; import com.microsoft.playwright.Page; -import com.saucelabs.bindings.SaucePlaywrightSession; -import com.saucelabs.extensions.SaucePlaywrightExtension; +import com.saucelabs.playwright.SauceExtension; +import com.saucelabs.playwright.SauceSession; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.RegisterExtension; public class TestBase { - SaucePlaywrightSession session; + SauceSession session; Page page; - @RegisterExtension - public SaucePlaywrightExtension sauceExtension = new SaucePlaywrightExtension(); + @RegisterExtension public static SauceExtension sauceExtension = new SauceExtension(); @BeforeEach - public void setUp(SaucePlaywrightSession session, Page page) { + public void setUp(SauceSession session, Page page) { this.session = session; this.page = page; } - - static { - System.setProperty("sauce.build.name", "Playwright Sauce Demo"); - System.setProperty("sauce.build.number", String.valueOf(System.currentTimeMillis())); - } } diff --git a/playwright-examples/src/test/java/com/saucelabs/StandaloneTest.java b/playwright-examples/src/test/java/com/saucelabs/StandaloneTest.java index 0e5c6f30..fd83187e 100644 --- a/playwright-examples/src/test/java/com/saucelabs/StandaloneTest.java +++ b/playwright-examples/src/test/java/com/saucelabs/StandaloneTest.java @@ -14,10 +14,15 @@ import com.microsoft.playwright.Page; import com.microsoft.playwright.Playwright; import com.microsoft.playwright.options.RequestOptions; -import com.saucelabs.saucerest.DataCenter; -import com.saucelabs.saucerest.SauceREST; import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.nio.file.Paths; +import java.time.Duration; +import java.util.Base64; import java.util.regex.Pattern; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; @@ -34,7 +39,8 @@ public class StandaloneTest { static final String SAUCE_USERNAME = System.getenv("SAUCE_USERNAME"); static final String SAUCE_ACCESS_KEY = System.getenv("SAUCE_ACCESS_KEY"); static final String SAUCE_URL = "https://ondemand.us-west-1.saucelabs.com/wd/hub/"; - static SauceREST sauceREST = new SauceREST(SAUCE_USERNAME, SAUCE_ACCESS_KEY, DataCenter.US_WEST); + static final String SAUCE_API_URL = "https://api.us-west-1.saucelabs.com"; + static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); static APIRequestContext request; static Playwright playwright; @RegisterExtension public SauceTestWatcher watcher = new SauceTestWatcher(); @@ -119,7 +125,9 @@ void createSession() { void launchBrowserAndCreatePage(TestInfo testInfo) throws IOException { createSession(); this.testInfo = testInfo; - sauceREST.getJobsEndpoint().changeName(sessionId, testInfo.getDisplayName()); + JsonObject renamePayload = new JsonObject(); + renamePayload.addProperty("name", testInfo.getDisplayName()); + updateJob(sessionId, renamePayload); browser = playwright.chromium().connectOverCDP(cdpEndpoint); // Maximize browser @@ -136,6 +144,26 @@ void closeContextAndWindow() { request.delete("session/" + sessionId); } + static void updateJob(String sessionId, JsonObject payload) throws IOException { + String credentials = SAUCE_USERNAME + ":" + SAUCE_ACCESS_KEY; + String authHeader = + "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + + HttpRequest httpRequest = + HttpRequest.newBuilder( + URI.create(SAUCE_API_URL + "/rest/v1/" + SAUCE_USERNAME + "/jobs/" + sessionId)) + .timeout(Duration.ofSeconds(30)) + .header("Authorization", authHeader) + .header("Content-Type", "application/json") + .method("PUT", HttpRequest.BodyPublishers.ofString(payload.toString())) + .build(); + try { + HTTP_CLIENT.send(httpRequest, HttpResponse.BodyHandlers.discarding()); + } catch (InterruptedException e) { + throw new IOException(e); + } + } + @Test void shouldClickButton() { page.navigate( @@ -172,21 +200,22 @@ public class SauceTestWatcher implements TestWatcher { @Override public void testSuccessful(ExtensionContext context) { printResults(); - try { - sauceREST.getJobsEndpoint().passed(sessionId); - } catch (Exception e) { - System.out.println("Problem setting job as passed: " + e); - } + reportResult(true); } @Override public void testFailed(ExtensionContext context, Throwable cause) { printResults(); + reportResult(false); + } + private void reportResult(boolean passed) { + JsonObject resultPayload = new JsonObject(); + resultPayload.addProperty("passed", passed); try { - sauceREST.getJobsEndpoint().failed(sessionId); - } catch (Exception e) { - System.out.println("Problem setting job as failed: " + e); + updateJob(sessionId, resultPayload); + } catch (IOException e) { + System.out.println("Problem setting job result: " + e); } } diff --git a/playwright-examples/src/test/java/com/saucelabs/bindings/SaucePlaywrightSession.java b/playwright-examples/src/test/java/com/saucelabs/bindings/SaucePlaywrightSession.java deleted file mode 100644 index bcb09207..00000000 --- a/playwright-examples/src/test/java/com/saucelabs/bindings/SaucePlaywrightSession.java +++ /dev/null @@ -1,133 +0,0 @@ -package com.saucelabs.bindings; - -import com.microsoft.playwright.APIRequest; -import com.microsoft.playwright.APIRequestContext; -import com.microsoft.playwright.APIResponse; -import com.microsoft.playwright.Browser; -import com.microsoft.playwright.Playwright; -import com.microsoft.playwright.options.RequestOptions; -import com.saucelabs.saucebindings.DataCenter; -import com.saucelabs.saucebindings.SauceRest; -import java.net.MalformedURLException; -import java.net.URL; -import java.util.Collections; -import java.util.Map; -import lombok.Getter; -import lombok.Setter; -import org.json.JSONObject; -import org.openqa.selenium.InvalidArgumentException; -import org.openqa.selenium.remote.SessionId; - -public class SaucePlaywrightSession { - @Getter protected Playwright playwright; - @Getter @Setter private DataCenter dataCenter = DataCenter.US_WEST; - private final Map capabilities; - @Setter private URL sauceUrl; - @Getter private Boolean result; - private String id; - private String cdpEndpoint; - private SauceRest rest; - private APIRequestContext request; - private Browser browser; - - public SaucePlaywrightSession(Map capabilities) { - this.capabilities = capabilities; - } - - public Browser start() { - this.browser = createPlaywrightSession(); - - this.rest = sauceRest(); - return browser; - } - - SauceRest sauceRest() { - return new SauceRest(this.dataCenter, new SessionId(id)); - } - - private Browser createPlaywrightSession() { - this.playwright = Playwright.create(); - this.request = - playwright - .request() - .newContext( - new APIRequest.NewContextOptions().setBaseURL(String.valueOf(getSauceUrl()))); - - JSONObject jsonPayload = new JSONObject(capabilities); - RequestOptions options = - RequestOptions.create() - .setMethod("POST") - .setData(jsonPayload.toString()) - .setMaxRedirects(5) - .setTimeout(120000); - APIResponse newSessionResponse = request.fetch("session", options); - JSONObject response = new JSONObject(newSessionResponse.text()).getJSONObject("value"); - - this.id = response.getString("sessionId"); - this.cdpEndpoint = response.getJSONObject("capabilities").getString("se:cdp"); - - return playwright.chromium().connectOverCDP(cdpEndpoint); - } - - public void annotate(String comment) { - RequestOptions options = - RequestOptions.create() - .setData("{\"script\": \"sauce:context=" + comment + "\", \"args\": []}"); - - request.post("session/" + id + "/execute/sync", options); - } - - public void addTag(String tag) { - rest.addTags(Collections.singletonList(tag)); - } - - public URL getSauceUrl() { - try { - if (sauceUrl != null) { - return sauceUrl; - } else { - return new URL(getDataCenter().getEndpoint() + "/"); - } - } catch (MalformedURLException e) { - throw new InvalidArgumentException("Invalid URL", e); - } - } - - public void stop(Boolean passed) { - try { - this.result = passed; - printToConsole(); - - rest.setResult(result); - browser.close(); - request.delete("session/" + id); - playwright.close(); - } catch (Exception e) { - if (rest != null) { - rest.stop(); - } - } finally { - browser = null; - playwright = null; - } - } - - @SuppressWarnings("unchecked") - private String getTestName() { - Map caps = (Map) capabilities.get("capabilities"); - Map alwaysMatch = (Map) caps.get("alwaysMatch"); - Map sauceOptions = (Map) alwaysMatch.get("sauce:options"); - return sauceOptions.get("name").toString(); - } - - public void printToConsole() { - // Add output for the Sauce OnDemand Jenkins plugin - // The first print statement will automatically populate links on Jenkins to Sauce - // The second print statement will output the job link to logging/console - String sauceReporter = - String.format("SauceOnDemandSessionID=%s job-name=%s", this.id, getTestName()); - String sauceTestLink = - String.format("Test Job Link:" + getDataCenter().getTestLink() + "%s", this.id); - System.out.print(sauceReporter + "\n" + sauceTestLink + "\n"); - } -} diff --git a/playwright-examples/src/test/java/com/saucelabs/extensions/SaucePlaywrightExtension.java b/playwright-examples/src/test/java/com/saucelabs/extensions/SaucePlaywrightExtension.java deleted file mode 100644 index 8d550aff..00000000 --- a/playwright-examples/src/test/java/com/saucelabs/extensions/SaucePlaywrightExtension.java +++ /dev/null @@ -1,132 +0,0 @@ -package com.saucelabs.extensions; - -import com.microsoft.playwright.Browser; -import com.microsoft.playwright.Page; -import com.saucelabs.bindings.SaucePlaywrightSession; -import com.saucelabs.saucebindings.CITools; -import com.saucelabs.saucebindings.DataCenter; -import java.util.HashMap; -import java.util.Map; -import java.util.logging.Logger; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.extension.BeforeEachCallback; -import org.junit.jupiter.api.extension.ExtensionContext; -import org.junit.jupiter.api.extension.ParameterContext; -import org.junit.jupiter.api.extension.ParameterResolver; -import org.junit.jupiter.api.extension.TestWatcher; - -public class SaucePlaywrightExtension - implements TestWatcher, BeforeEachCallback, ParameterResolver { - private static final Logger LOGGER = Logger.getLogger(SaucePlaywrightExtension.class.getName()); - private static final String BROWSER_NAME = System.getProperty("sauce.browser", "Chrome"); - protected DataCenter dataCenter; - - public SaucePlaywrightExtension() { - this(DataCenter.US_WEST); - } - - private SaucePlaywrightExtension(DataCenter dataCenter) { - this.dataCenter = dataCenter; - } - - @Override - public void beforeEach(ExtensionContext context) { - SaucePlaywrightSession session = new SaucePlaywrightSession(createOptions(context)); - session.setDataCenter(dataCenter); - Browser browser = session.start(); - - Browser.NewContextOptions newContextOptions = - new Browser.NewContextOptions().setViewportSize(null); - Page page = browser.newContext(newContextOptions).newPage(); - - getStore(context).put("session", session); - getStore(context).put("browser", browser); - getStore(context).put("page", page); - } - - public Map createOptions(ExtensionContext context) { - Map sauceOptions = new HashMap<>(); - sauceOptions.put("username", System.getenv("SAUCE_USERNAME")); - sauceOptions.put("accessKey", System.getenv("SAUCE_ACCESS_KEY")); - sauceOptions.put("devTools", true); - sauceOptions.put("_tptCommanderVersion", "stable"); - sauceOptions.put("name", getTestName(context)); - sauceOptions.put("build", CITools.getBuildName() + ": " + CITools.getBuildNumber()); - - Map sessionRequest = new HashMap<>(); - sessionRequest.put("platformName", "macOS 13"); - sessionRequest.put("browserName", BROWSER_NAME); - sessionRequest.put("sauce:options", sauceOptions); - - Map capabilities = new HashMap<>(); - capabilities.put("alwaysMatch", sessionRequest); - - Map payload = new HashMap<>(); - payload.put("capabilities", capabilities); - - return payload; - } - - private String getTestName(ExtensionContext context) { - // Use value specified by @DisplayName annotation if present - if (context.getRequiredTestMethod().getDeclaredAnnotation(DisplayName.class) != null) { - return context.getDisplayName(); - } else { - String className = context.getRequiredTestClass().getSimpleName(); - String methodName = context.getRequiredTestMethod().getName(); - return className + ": " + methodName; - } - } - - private ExtensionContext.Store getStore(ExtensionContext context) { - return context.getStore( - ExtensionContext.Namespace.create(getClass(), context.getRequiredTestMethod())); - } - - @Override - public void testSuccessful(ExtensionContext context) { - SaucePlaywrightSession session = (SaucePlaywrightSession) getStore(context).get("session"); - try { - session.stop(true); - } catch (Exception e) { - LOGGER.severe("Session quit prematurely; Allow SaucePlaywrightExtension to stop the test"); - throw e; - } - } - - @Override - public void testFailed(ExtensionContext context, Throwable cause) { - SaucePlaywrightSession session = (SaucePlaywrightSession) getStore(context).get("session"); - if (session != null) { - try { - session.stop(false); - } catch (Exception e) { - LOGGER.severe("Session quit prematurely; Allow SaucePlaywrightExtension to stop the test"); - throw e; - } - } - } - - @Override - public boolean supportsParameter( - ParameterContext parameterContext, ExtensionContext extensionContext) { - boolean session = parameterContext.getParameter().getType() == SaucePlaywrightSession.class; - boolean browser = parameterContext.getParameter().getType() == Browser.class; - boolean page = parameterContext.getParameter().getType() == Page.class; - return session || browser || page; - } - - @Override - public Object resolveParameter( - ParameterContext parameterContext, ExtensionContext extensionContext) { - if (parameterContext.getParameter().getType() == Browser.class) { - return getStore(extensionContext).get("browser"); - } else if (parameterContext.getParameter().getType() == SaucePlaywrightSession.class) { - return getStore(extensionContext).get("session"); - } else if (parameterContext.getParameter().getType() == Page.class) { - return getStore(extensionContext).get("page"); - } else { - throw new RuntimeException("Only browser, session and page instances are supported"); - } - } -} diff --git a/playwright-examples/src/test/java/com/saucelabs/playwright/BuildInfo.java b/playwright-examples/src/test/java/com/saucelabs/playwright/BuildInfo.java new file mode 100644 index 00000000..ad1cc526 --- /dev/null +++ b/playwright-examples/src/test/java/com/saucelabs/playwright/BuildInfo.java @@ -0,0 +1,24 @@ +package com.saucelabs.playwright; + +/** + * Build name/number shared by every session in a run. Computed once via JVM class-loading (a single + * {@code private static final} field), which is exactly-once and thread-safe without needing any + * explicit synchronization - unlike a wall-clock timestamp read independently by each + * thread/process, this can't drift apart across parallel execution units within one JVM. + */ +public final class BuildInfo { + public static final String BUILD_NAME = resolveBuildName(); + public static final String BUILD_NUMBER = resolveBuildNumber(); + + private BuildInfo() {} + + private static String resolveBuildName() { + String workflow = System.getenv("GITHUB_WORKFLOW"); + return workflow != null ? workflow : "Playwright Java Sauce Demo (local)"; + } + + private static String resolveBuildNumber() { + String runNumber = System.getenv("GITHUB_RUN_NUMBER"); + return runNumber != null ? runNumber : String.valueOf(System.currentTimeMillis()); + } +} diff --git a/playwright-examples/src/test/java/com/saucelabs/playwright/SauceExtension.java b/playwright-examples/src/test/java/com/saucelabs/playwright/SauceExtension.java new file mode 100644 index 00000000..cd9c9c30 --- /dev/null +++ b/playwright-examples/src/test/java/com/saucelabs/playwright/SauceExtension.java @@ -0,0 +1,169 @@ +package com.saucelabs.playwright; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolver; + +/** + * Creates Sauce Playwright sessions through the native WebSocket endpoint and injects + * Browser/Page/SauceSession into test methods. + * + *

Session grouping follows the same switch JUnit5's own parallel executor uses: {@code + * junit.jupiter.execution.parallel.mode.default}. When it's {@code same_thread} (this project's + * default), one session is shared across every method of a test class - JUnit5 schedules a + * same_thread class's methods on a single thread with no overlap, confirmed empirically to never + * interleave across classes even when the thread pool is smaller than the number of classes. When + * it's {@code concurrent}, every test method gets its own independent session instead, since + * methods of one class may then run on different threads at once and can't safely share a single + * browser connection. + * + *

A session whose test fails is closed and reported immediately rather than handed to whatever + * runs next in the same group - a session that just saw a failure isn't assumed clean. + */ +public class SauceExtension + implements BeforeEachCallback, AfterEachCallback, AfterAllCallback, ParameterResolver { + + private static final ExtensionContext.Namespace NAMESPACE = + ExtensionContext.Namespace.create(SauceExtension.class); + private static final Object SESSION_KEY = new Object(); + private static final Object PAGE_KEY = new Object(); + + private static final String BROWSER_NAME = System.getProperty("sauce.browser.name", "chromium"); + private static final SauceRegion REGION = SauceRegion.US_WEST; + + private static final class SessionHolder implements ExtensionContext.Store.CloseableResource { + private final Playwright playwright; + private final SauceSession session; + private final Browser browser; + private volatile boolean failed; + + SessionHolder(Playwright playwright, SauceSession session, Browser browser) { + this.playwright = playwright; + this.session = session; + this.browser = browser; + } + + @Override + public void close() { + session.reportResult(!failed); + session.printJobLink(); + browser.close(); + playwright.close(); + } + } + + @Override + public void beforeEach(ExtensionContext context) { + SessionHolder holder = + sessionStore(context) + .getOrComputeIfAbsent(SESSION_KEY, key -> createHolder(context), SessionHolder.class); + + Page page = + holder.browser.newContext(new Browser.NewContextOptions().setViewportSize(null)).newPage(); + context.getStore(NAMESPACE).put(PAGE_KEY, page); + } + + @Override + public void afterEach(ExtensionContext context) { + Page page = context.getStore(NAMESPACE).remove(PAGE_KEY, Page.class); + if (page != null && !page.isClosed()) { + page.close(); + } + + // Deciding pass/fail has to happen here rather than via TestWatcher: by the time + // TestWatcher's callbacks fire, the method-level store may already be closed, and + // getExecutionException() is exactly the outcome-inspection API meant for use from an + // AfterEachCallback, unlike TestWatcher which observes after the fact. + boolean failed = context.getExecutionException().isPresent(); + if (failed) { + SessionHolder holder = sessionStore(context).remove(SESSION_KEY, SessionHolder.class); + if (holder != null) { + holder.failed = true; + holder.close(); + } + } else if (!isGroupedMode(context)) { + SessionHolder holder = sessionStore(context).remove(SESSION_KEY, SessionHolder.class); + if (holder != null) { + holder.close(); + } + } + } + + @Override + public void afterAll(ExtensionContext context) { + // Meaningful in grouped mode only: closes/reports whatever session is still open for this + // class once every method has run. In per-test mode each session is already closed right + // after its own test by afterEach above. + SessionHolder holder = context.getStore(NAMESPACE).remove(SESSION_KEY, SessionHolder.class); + if (holder != null) { + holder.close(); + } + } + + private SessionHolder createHolder(ExtensionContext context) { + Playwright playwright = Playwright.create(); + SauceSession session = + SauceSession.create( + REGION, + requireEnv("SAUCE_USERNAME"), + requireEnv("SAUCE_ACCESS_KEY"), + BROWSER_NAME, + sessionName(context)); + Browser browser = session.connect(playwright, BROWSER_NAME); + return new SessionHolder(playwright, session, browser); + } + + private String sessionName(ExtensionContext context) { + String className = context.getRequiredTestClass().getSimpleName(); + return isGroupedMode(context) + ? className + : className + ": " + context.getRequiredTestMethod().getName(); + } + + private ExtensionContext.Store sessionStore(ExtensionContext context) { + if (isGroupedMode(context)) { + return context.getParent().orElseThrow().getStore(NAMESPACE); + } + return context.getStore(NAMESPACE); + } + + private boolean isGroupedMode(ExtensionContext context) { + return !"concurrent" + .equalsIgnoreCase( + context + .getConfigurationParameter("junit.jupiter.execution.parallel.mode.default") + .orElse("same_thread")); + } + + private static String requireEnv(String name) { + String value = System.getenv(name); + if (value == null || value.isEmpty()) { + throw new IllegalStateException("Missing required environment variable: " + name); + } + return value; + } + + @Override + public boolean supportsParameter( + ParameterContext parameterContext, ExtensionContext extensionContext) { + Class type = parameterContext.getParameter().getType(); + return type == Browser.class || type == Page.class || type == SauceSession.class; + } + + @Override + public Object resolveParameter( + ParameterContext parameterContext, ExtensionContext extensionContext) { + Class type = parameterContext.getParameter().getType(); + if (type == Page.class) { + return extensionContext.getStore(NAMESPACE).get(PAGE_KEY, Page.class); + } + SessionHolder holder = sessionStore(extensionContext).get(SESSION_KEY, SessionHolder.class); + return type == Browser.class ? holder.browser : holder.session; + } +} diff --git a/playwright-examples/src/test/java/com/saucelabs/playwright/SauceRegion.java b/playwright-examples/src/test/java/com/saucelabs/playwright/SauceRegion.java new file mode 100644 index 00000000..09a58077 --- /dev/null +++ b/playwright-examples/src/test/java/com/saucelabs/playwright/SauceRegion.java @@ -0,0 +1,29 @@ +package com.saucelabs.playwright; + +/** Sauce Labs data centers reachable through the native Playwright WebSocket endpoint. */ +public enum SauceRegion { + US_WEST("us-west-1"), + US_EAST("us-east-1"), + EU_CENTRAL("eu-central-1"), + APAC_SOUTHEAST("apac-southeast-1"); + + private final String value; + + SauceRegion(String value) { + this.value = value; + } + + public String ondemandUrl() { + return "https://ondemand." + value + ".saucelabs.com"; + } + + public String apiUrl() { + return "https://api." + value + ".saucelabs.com"; + } + + public String testLink() { + return value.equals("us-west-1") + ? "https://app.saucelabs.com/tests/" + : "https://app." + value + ".saucelabs.com/tests/"; + } +} diff --git a/playwright-examples/src/test/java/com/saucelabs/playwright/SauceSession.java b/playwright-examples/src/test/java/com/saucelabs/playwright/SauceSession.java new file mode 100644 index 00000000..5e9674ac --- /dev/null +++ b/playwright-examples/src/test/java/com/saucelabs/playwright/SauceSession.java @@ -0,0 +1,199 @@ +package com.saucelabs.playwright; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.Playwright; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import org.json.JSONObject; + +/** + * A single Sauce Labs job created through the native Playwright WebSocket endpoint (POST + * /playwright/session), as opposed to the old WebDriver-session + CDP approach. Unlike CDP, this + * endpoint's connection is a plain WebSocket, so it drives Chromium, Firefox and WebKit alike. + */ +public class SauceSession { + private static final Duration VM_PREP_TIMEOUT = Duration.ofSeconds(120); + private static final HttpClient HTTP_CLIENT = + HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(); + + private final SauceRegion region; + private final String username; + private final String accessKey; + private final String sessionId; + private final String wsEndpoint; + private final String sessionName; + + private SauceSession( + SauceRegion region, + String username, + String accessKey, + String sessionId, + String wsEndpoint, + String sessionName) { + this.region = region; + this.username = username; + this.accessKey = accessKey; + this.sessionId = sessionId; + this.wsEndpoint = wsEndpoint; + this.sessionName = sessionName; + } + + public static SauceSession create( + SauceRegion region, + String username, + String accessKey, + String browserName, + String sessionName) { + String build = BuildInfo.BUILD_NAME + ": " + BuildInfo.BUILD_NUMBER; + JSONObject payload = + new JSONObject() + .put("browserName", browserName) + .put("platformName", "Linux") + .put("playwrightVersion", installedPlaywrightVersion()) + .put("sauce:options", new JSONObject().put("name", sessionName).put("build", build)); + + JSONObject response = + requestFollowing303s( + region.ondemandUrl() + "/playwright/session", username, accessKey, payload); + JSONObject value = response.has("value") ? response.getJSONObject("value") : response; + + return new SauceSession( + region, + username, + accessKey, + value.getString("sessionId"), + value.getString("wsEndpoint"), + sessionName); + } + + private static JSONObject requestFollowing303s( + String url, String username, String accessKey, JSONObject payload) { + String authHeader = basicAuthHeader(username, accessKey); + String body = payload.toString(); + String nextUrl = url; + String method = "POST"; + String nextBody = body; + + try { + while (true) { + HttpRequest.Builder builder = + HttpRequest.newBuilder(URI.create(nextUrl)) + .timeout(VM_PREP_TIMEOUT) + .header("Authorization", authHeader) + .header("Content-Type", "application/json"); + builder = + "POST".equals(method) + ? builder.POST(HttpRequest.BodyPublishers.ofString(nextBody)) + : builder.GET(); + + HttpResponse response = + HTTP_CLIENT.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 303) { + if (response.statusCode() >= 400) { + throw new SauceSessionException( + "Sauce session creation failed with status " + + response.statusCode() + + ": " + + response.body()); + } + return new JSONObject(response.body()); + } + + String location = + response + .headers() + .firstValue("Location") + .orElseThrow( + () -> + new SauceSessionException("Sauce responded 303 without a Location header")); + nextUrl = location.startsWith("http") ? location : (region(url) + location); + method = "GET"; + } + } catch (IOException | InterruptedException e) { + throw new SauceSessionException("Failed to create Sauce Playwright session", e); + } + } + + private static String region(String originalUrl) { + URI uri = URI.create(originalUrl); + return uri.getScheme() + "://" + uri.getAuthority(); + } + + private static String basicAuthHeader(String username, String accessKey) { + String credentials = username + ":" + accessKey; + return "Basic " + + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + } + + private static String installedPlaywrightVersion() { + String version = Playwright.class.getPackage().getImplementationVersion(); + if (version == null) { + throw new SauceSessionException( + "Could not read the installed Playwright version from the JAR manifest"); + } + int secondDot = version.indexOf('.', version.indexOf('.') + 1); + return secondDot == -1 ? version : version.substring(0, secondDot); + } + + public Browser connect(Playwright playwright, String browserName) { + BrowserType browserType = browserTypeFor(playwright, browserName); + return browserType.connect(wsEndpoint + "?browser=" + browserName); + } + + private static BrowserType browserTypeFor(Playwright playwright, String browserName) { + switch (browserName) { + case "firefox": + return playwright.firefox(); + case "webkit": + return playwright.webkit(); + case "chromium": + return playwright.chromium(); + default: + throw new SauceSessionException("Unsupported browser name: " + browserName); + } + } + + public void reportResult(boolean passed) { + String url = region.apiUrl() + "/rest/v1/" + username + "/jobs/" + sessionId; + JSONObject payload = new JSONObject().put("passed", passed); + + try { + HttpRequest request = + HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(30)) + .header("Authorization", basicAuthHeader(username, accessKey)) + .header("Content-Type", "application/json") + .method("PUT", HttpRequest.BodyPublishers.ofString(payload.toString())) + .build(); + HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.discarding()); + } catch (IOException | InterruptedException e) { + throw new SauceSessionException("Failed to report result for Sauce session " + sessionId, e); + } + } + + public void printJobLink() { + // Add output for the Sauce OnDemand Jenkins plugin + // The first print statement will automatically populate links on Jenkins to Sauce + // The second print statement will output the job link to logging/console + System.out.printf("SauceOnDemandSessionID=%s job-name=%s%n", sessionId, sessionName); + System.out.printf("Test Job Link: %s%s%n", region.testLink(), sessionId); + } + + public static class SauceSessionException extends RuntimeException { + public SauceSessionException(String message) { + super(message); + } + + public SauceSessionException(String message, Throwable cause) { + super(message, cause); + } + } +}