-
Notifications
You must be signed in to change notification settings - Fork 276
fix: Replaced classLoader in DriverJar #1811
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
a7830eb
2b8f04d
3c925b1
04b40cf
999cef6
2b34498
ef25b16
3da9be6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | ||
| xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> | ||
| <modelVersion>4.0.0</modelVersion> | ||
| <parent> | ||
| <groupId>org.springframework.boot</groupId> | ||
| <artifactId>spring-boot-starter-parent</artifactId> | ||
| <version>2.4.3</version> | ||
| </parent> | ||
| <groupId>com.microsoft.playwright</groupId> | ||
| <artifactId>test-spring-classloader</artifactId> | ||
| <version>1.50.0-SNAPSHOT</version> | ||
| <name>Test Playwright With Spring Boot</name> | ||
| <properties> | ||
| <spring.version>2.4.3</spring.version> | ||
| </properties> | ||
| <dependencies> | ||
| <dependency> | ||
| <groupId>org.springframework.boot</groupId> | ||
| <artifactId>spring-boot-starter</artifactId> | ||
| </dependency> | ||
| <dependency> | ||
| <groupId>com.microsoft.playwright</groupId> | ||
| <artifactId>playwright</artifactId> | ||
| <version>${project.version}</version> | ||
| </dependency> | ||
| </dependencies> | ||
| <build> | ||
| <plugins> | ||
| <plugin> | ||
| <groupId>org.springframework.boot</groupId> | ||
| <artifactId>spring-boot-maven-plugin</artifactId> | ||
| </plugin> | ||
| </plugins> | ||
| </build> | ||
| </project> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| /* | ||
| * Copyright (c) Microsoft Corporation. | ||
| * | ||
| * 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 | ||
| * | ||
| * http://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. | ||
| */ | ||
|
|
||
| package com.microsoft.playwright.springboottest; | ||
|
|
||
| import com.microsoft.playwright.impl.driver.Driver; | ||
|
|
||
| import java.io.IOException; | ||
| import java.net.URI; | ||
| import java.net.URISyntaxException; | ||
| import java.nio.file.*; | ||
| import java.util.Collections; | ||
| import java.util.Map; | ||
| import java.util.concurrent.TimeUnit; | ||
|
|
||
| public class DriverJar extends Driver { | ||
| private static final String PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD"; | ||
| private static final String SELENIUM_REMOTE_URL = "SELENIUM_REMOTE_URL"; | ||
| private final Path driverTempDir; | ||
| private Path preinstalledNodePath; | ||
|
|
||
| public DriverJar() throws IOException { | ||
| // Allow specifying custom path for the driver installation | ||
| // See https://github.com/microsoft/playwright-java/issues/728 | ||
| String alternativeTmpdir = System.getProperty("playwright.driver.tmpdir"); | ||
| String prefix = "playwright-java-"; | ||
| driverTempDir = alternativeTmpdir == null | ||
| ? Files.createTempDirectory(prefix) | ||
| : Files.createTempDirectory(Paths.get(alternativeTmpdir), prefix); | ||
| driverTempDir.toFile().deleteOnExit(); | ||
| String nodePath = System.getProperty("playwright.nodejs.path"); | ||
| if (nodePath != null) { | ||
| preinstalledNodePath = Paths.get(nodePath); | ||
| if (!Files.exists(preinstalledNodePath)) { | ||
| throw new RuntimeException("Invalid Node.js path specified: " + nodePath); | ||
| } | ||
| } | ||
| logMessage("created DriverJar: " + driverTempDir); | ||
| } | ||
|
|
||
| @Override | ||
| protected void initialize(Boolean installBrowsers) throws Exception { | ||
| if (preinstalledNodePath == null && env.containsKey(PLAYWRIGHT_NODEJS_PATH)) { | ||
| preinstalledNodePath = Paths.get(env.get(PLAYWRIGHT_NODEJS_PATH)); | ||
| if (!Files.exists(preinstalledNodePath)) { | ||
| throw new RuntimeException("Invalid Node.js path specified: " + preinstalledNodePath); | ||
| } | ||
| } else if (preinstalledNodePath != null) { | ||
| // Pass the env variable to the driver process. | ||
| env.put(PLAYWRIGHT_NODEJS_PATH, preinstalledNodePath.toString()); | ||
| } | ||
| extractDriverToTempDir(); | ||
| logMessage("extracted driver from jar to " + driverDir()); | ||
| if (installBrowsers) | ||
| installBrowsers(env); | ||
| } | ||
|
|
||
| private void installBrowsers(Map<String, String> env) throws IOException, InterruptedException { | ||
| String skip = env.get(PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD); | ||
| if (skip == null) { | ||
| skip = System.getenv(PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD); | ||
| } | ||
| if (skip != null && !"0".equals(skip) && !"false".equals(skip)) { | ||
| logMessage("Skipping browsers download because `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD` env variable is set"); | ||
| return; | ||
| } | ||
| if (env.get(SELENIUM_REMOTE_URL) != null || System.getenv(SELENIUM_REMOTE_URL) != null) { | ||
| logMessage("Skipping browsers download because `SELENIUM_REMOTE_URL` env variable is set"); | ||
| return; | ||
| } | ||
| Path driver = driverDir(); | ||
| if (!Files.exists(driver)) { | ||
| throw new RuntimeException("Failed to find driver: " + driver); | ||
| } | ||
| ProcessBuilder pb = createProcessBuilder(); | ||
| pb.command().add("install"); | ||
| pb.redirectError(ProcessBuilder.Redirect.INHERIT); | ||
| pb.redirectOutput(ProcessBuilder.Redirect.INHERIT); | ||
| Process p = pb.start(); | ||
| boolean result = p.waitFor(10, TimeUnit.MINUTES); | ||
| if (!result) { | ||
| p.destroy(); | ||
| throw new RuntimeException("Timed out waiting for browsers to install"); | ||
| } | ||
| if (p.exitValue() != 0) { | ||
| throw new RuntimeException("Failed to install browsers, exit code: " + p.exitValue()); | ||
| } | ||
| } | ||
|
|
||
| private static boolean isExecutable(Path filePath) { | ||
| String name = filePath.getFileName().toString(); | ||
| return name.endsWith(".sh") || name.endsWith(".exe") || !name.contains("."); | ||
| } | ||
|
|
||
| private FileSystem initFileSystem(URI uri) throws IOException { | ||
| try { | ||
| return FileSystems.newFileSystem(uri, Collections.emptyMap()); | ||
| } catch (FileSystemAlreadyExistsException e) { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| public static URI getDriverResourceURI() throws URISyntaxException { | ||
| ClassLoader classloader = com.microsoft.playwright.impl.driver.jar.DriverJar.class.getClassLoader(); | ||
| return classloader.getResource("driver/" + platformDir()).toURI(); | ||
| } | ||
|
|
||
| void extractDriverToTempDir() throws URISyntaxException, IOException { | ||
| URI originalUri = getDriverResourceURI(); | ||
| URI uri = maybeExtractNestedJar(originalUri); | ||
|
|
||
| // Create zip filesystem if loading from jar. | ||
| try (FileSystem fileSystem = "jar".equals(uri.getScheme()) ? initFileSystem(uri) : null) { | ||
| Path srcRoot = Paths.get(uri); | ||
| // jar file system's .relativize gives wrong results when used with | ||
| // spring-boot-maven-plugin, convert to the default filesystem to | ||
| // have predictable results. | ||
| // See https://github.com/microsoft/playwright-java/issues/306 | ||
| Path srcRootDefaultFs = Paths.get(srcRoot.toString()); | ||
| Files.walk(srcRoot).forEach(fromPath -> { | ||
| if (preinstalledNodePath != null) { | ||
| String fileName = fromPath.getFileName().toString(); | ||
| if ("node.exe".equals(fileName) || "node".equals(fileName)) { | ||
| return; | ||
| } | ||
| } | ||
| Path relative = srcRootDefaultFs.relativize(Paths.get(fromPath.toString())); | ||
| Path toPath = driverTempDir.resolve(relative.toString()); | ||
| try { | ||
| if (Files.isDirectory(fromPath)) { | ||
| Files.createDirectories(toPath); | ||
| } else { | ||
| Files.copy(fromPath, toPath); | ||
| if (isExecutable(toPath)) { | ||
| toPath.toFile().setExecutable(true, true); | ||
| } | ||
| } | ||
| toPath.toFile().deleteOnExit(); | ||
| } catch (IOException e) { | ||
| throw new RuntimeException("Failed to extract driver from " + uri + ", full uri: " + originalUri, e); | ||
| } | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| private URI maybeExtractNestedJar(final URI uri) throws URISyntaxException { | ||
| if (!"jar".equals(uri.getScheme())) { | ||
| return uri; | ||
| } | ||
| final String JAR_URL_SEPARATOR = "!/"; | ||
| String[] parts = uri.toString().split("!/"); | ||
| if (parts.length != 3) { | ||
| return uri; | ||
| } | ||
| String innerJar = String.join(JAR_URL_SEPARATOR, parts[0], parts[1]); | ||
| URI jarUri = new URI(innerJar); | ||
| try (FileSystem fs = FileSystems.newFileSystem(jarUri, Collections.emptyMap())) { | ||
| Path fromPath = Paths.get(jarUri); | ||
| Path toPath = driverTempDir.resolve(fromPath.getFileName().toString()); | ||
| Files.copy(fromPath, toPath); | ||
| toPath.toFile().deleteOnExit(); | ||
| return new URI("jar:" + toPath.toUri() + JAR_URL_SEPARATOR + parts[2]); | ||
| } catch (IOException e) { | ||
| throw new RuntimeException("Failed to extract driver's nested .jar from " + jarUri + "; full uri: " + uri, e); | ||
| } | ||
| } | ||
|
|
||
| private static String platformDir() { | ||
| String name = System.getProperty("os.name").toLowerCase(); | ||
| String arch = System.getProperty("os.arch").toLowerCase(); | ||
|
|
||
| if (name.contains("windows")) { | ||
| return "win32_x64"; | ||
| } | ||
| if (name.contains("linux")) { | ||
| if (arch.equals("aarch64")) { | ||
| return "linux-arm64"; | ||
| } else { | ||
| return "linux"; | ||
| } | ||
| } | ||
| if (name.contains("mac os x")) { | ||
| if (arch.equals("aarch64")) { | ||
| return "mac-arm64"; | ||
| } else { | ||
| return "mac"; | ||
| } | ||
| } | ||
| throw new RuntimeException("Unexpected os.name value: " + name); | ||
| } | ||
|
|
||
| @Override | ||
| public Path driverDir() { | ||
| return driverTempDir; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| package com.microsoft.playwright.springboottest; | ||
|
|
||
| import com.microsoft.playwright.*; | ||
| import org.springframework.boot.CommandLineRunner; | ||
| import org.springframework.boot.SpringApplication; | ||
| import org.springframework.boot.autoconfigure.SpringBootApplication; | ||
|
|
||
| import java.util.concurrent.CompletableFuture; | ||
|
|
||
| @SpringBootApplication | ||
| public class TestApp implements CommandLineRunner { | ||
|
|
||
| public static void main(String[] args) { | ||
| SpringApplication.run(TestApp.class, args); | ||
| } | ||
|
|
||
| public void run(String... args) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we just run existing
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The key difference is that the new test uses CompletableFuture for asynchronous execution. While the existing test works fine even in the Docker environment, i encountered an issue where DriverJar could not be read when executed from a new thread created by CompletableFuture inside the Docker container. That’s why this additional test was introduced.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In that case you let's just modify existing test to either always run playwright asynchronously or, if you want to keep testing sync code path as well, do it sync/async based on a command line flag and pass the flag only when running in docker. Something like this: public static void main(String[] args) {
if ("--async".equals(args[0])) {
CompletableFuture<Void> voidCompletableFuture = CompletableFuture.runAsync(() -> {
SpringApplication.run(TestApp.class, args);
});
voidCompletableFuture.join();
} else {
SpringApplication.run(TestApp.class, args);
}
}Would that work?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When running with --async, Spring Boot uses the main thread’s context class loader to load auto-configuration classes. However, in the asynchronous thread, this context class loader is not correctly inherited, which causes it to fail to locate the configuration files. 11:48:21.637 [ForkJoinPool.commonPool-worker-1] ERROR org.springframework.boot.SpringApplication - Application run failed
java.lang.IllegalArgumentException: No auto configuration classes found in META-INF/spring.factories. If you are using a custom packaging, make sure that file is correct.
at org.springframework.util.Assert.notEmpty(Assert.java:470)
at org.springframework.boot.autoconfigure.AutoConfigurationImportSelector.getCandidateConfigurations(AutoConfigurationImportSelector.java:180)
at org.springframework.boot.autoconfigure.AutoConfigurationImportSelector.getAutoConfigurationEntry(AutoConfigurationImportSelector.java:123)
at org.springframework.boot.autoconfigure.AutoConfigurationImportSelector$AutoConfigurationGroup.process(AutoConfigurationImportSelector.java:434)
at org.springframework.context.annotation.ConfigurationClassParser$DeferredImportSelectorGrouping.getImports(ConfigurationClassParser.java:879)
at org.springframework.context.annotation.ConfigurationClassParser$DeferredImportSelectorGroupingHandler.processGroupImports(ConfigurationClassParser.java:809)
at org.springframework.context.annotation.ConfigurationClassParser$DeferredImportSelectorHandler.process(ConfigurationClassParser.java:780)
at org.springframework.context.annotation.ConfigurationClassParser.parse(ConfigurationClassParser.java:193)
at org.springframework.context.annotation.ConfigurationClassPostProcessor.processConfigBeanDefinitions(ConfigurationClassPostProcessor.java:330)
at org.springframework.context.annotation.ConfigurationClassPostProcessor.postProcessBeanDefinitionRegistry(ConfigurationClassPostProcessor.java:246)
at org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanDefinitionRegistryPostProcessors(PostProcessorRegistrationDelegate.java:311)
at org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors(PostProcessorRegistrationDelegate.java:112)
at org.springframework.context.support.AbstractApplicationContext.invokeBeanFactoryPostProcessors(AbstractApplicationContext.java:745)
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:563)
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:767)
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:759)
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:426)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:326)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1311)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1300)
at com.microsoft.playwright.springboottest.TestApp.lambda$main$0(TestApp.java:16)
at java.base/java.util.concurrent.CompletableFuture$AsyncRun.run(CompletableFuture.java:1804)
at java.base/java.util.concurrent.CompletableFuture$AsyncRun.exec(CompletableFuture.java:1796)
at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:387)
at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1312)
at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1843)
at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1808)
at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:188)While it’s possible to manually pass the main thread’s context class loader to the async thread like below, doing so defeats the original purpose of testing the class loader behavior. public static void main(String[] args) {
if (args.length == 0) {
SpringApplication.run(TestApp.class, args);
}
else {
if ("--async".equals(args[0])) {
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
CompletableFuture<Void> voidCompletableFuture = CompletableFuture.runAsync(() -> {
Thread.currentThread().setContextClassLoader(contextClassLoader);
SpringApplication.run(TestApp.class, args);
});
voidCompletableFuture.join();
}
}
}For this reason, I think separating the test into a different package would make the intention clearer.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, I meant to just share the implementation of the actual test method, I guess it should have been something like this: @Override
public void run(String... args) {
if ("--async".equals(args[0])) {
runAsync();
} else {
runSync();
}
}
private void runAsync() {
CompletableFuture<Void> voidCompletableFuture = CompletableFuture.runAsync(() -> {
runSync();
});
voidCompletableFuture.join();
}
private void runSync() {
try (Playwright playwright = Playwright.create()) {
BrowserType browserType = getBrowserTypeFromEnv(playwright);
System.out.println("Running test with " + browserType.name());
Browser browser = browserType.launch();
BrowserContext context = browser.newContext();
Page page = context.newPage();
System.out.println(page.evaluate("'SUCCESS: did evaluate in page'"));
}
}Basically the logic from the existing test is in |
||
| System.out.println("Starting original Playwright test..."); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't need to test original Playwright. |
||
| CompletableFuture<Void> voidCompletableFuture = CompletableFuture.runAsync(() -> { | ||
| try (Playwright playwright = Playwright.create()) { | ||
| System.out.println("original Playwright test started, waiting for completion..."); | ||
| BrowserType browserType = getBrowserTypeFromEnv(playwright); | ||
| System.out.println("Running original test with " + browserType.name()); | ||
| Browser browser = browserType.launch(); | ||
| BrowserContext context = browser.newContext(); | ||
| Page page = context.newPage(); | ||
| System.out.println(page.evaluate("'SUCCESS: did evaluate in page'")); | ||
| } catch (Exception e) { | ||
| System.out.println("FAILED: " + e.toString()); | ||
| for (StackTraceElement ste : e.getStackTrace()) { | ||
| System.out.println("\tat " + ste); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| System.out.println("original Playwright test is running asynchronously, main thread will wait for it to complete."); | ||
|
|
||
| voidCompletableFuture.join(); | ||
|
|
||
| System.out.println("original Playwright test completed."); | ||
|
|
||
|
|
||
| System.out.println("Starting new Playwright test..."); | ||
|
|
||
| // Set the new driver implementation to use the DriverJar class | ||
| System.setProperty( "playwright.driver.impl", "com.microsoft.playwright.springboottest.DriverJar" ); | ||
|
|
||
| CompletableFuture<Void> voidCompletableFuture2 = CompletableFuture.runAsync(() -> { | ||
| try (Playwright playwright = Playwright.create()) { | ||
| System.out.println("new Playwright test started, waiting for completion..."); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How is this test different from the existing ? Can we just extend that one? From what I understand, the test only fails in a specific docker environment, so can you add it to the corresponding CI step, similar to this ? If it fails only in the Docker environment, you can run it as part of the existing Docker tests
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Understood. I’ll proceed accordingly. |
||
| BrowserType browserType = getBrowserTypeFromEnv(playwright); | ||
| System.out.println("Running new test with " + browserType.name()); | ||
| Browser browser = browserType.launch(); | ||
| BrowserContext context = browser.newContext(); | ||
| Page page = context.newPage(); | ||
| System.out.println(page.evaluate("'SUCCESS: did evaluate in page'")); | ||
| } catch (Exception e) { | ||
| System.out.println("FAILED: " + e.toString()); | ||
| for (StackTraceElement ste : e.getStackTrace()) { | ||
| System.out.println("\tat " + ste); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| System.out.println("new Playwright test is running asynchronously, main thread will wait for it to complete."); | ||
|
|
||
| voidCompletableFuture2.join(); | ||
|
|
||
| System.out.println("new Playwright test completed."); | ||
|
|
||
| } | ||
|
|
||
| static BrowserType getBrowserTypeFromEnv(Playwright playwright) { | ||
| String browserName = System.getenv("BROWSER"); | ||
|
|
||
| if (browserName == null) { | ||
| browserName = "chromium"; | ||
| } | ||
|
|
||
| switch (browserName) { | ||
| case "webkit": | ||
| return playwright.webkit(); | ||
| case "firefox": | ||
| return playwright.firefox(); | ||
| case "chromium": | ||
| return playwright.chromium(); | ||
| default: | ||
| throw new IllegalArgumentException("Unknown browser: " + browserName); | ||
| } | ||
| } | ||
|
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we are testing the behavior of our default DriverJar implementation let's drop this class. If the test fails before the change in DriverJar.java and starts passing after, it's good enough.