From 61db54e84894aaec5ee221cfef04c833dfaef6e0 Mon Sep 17 00:00:00 2001 From: Alexey Andreev Date: Sun, 7 Mar 2021 15:56:48 +0300 Subject: [PATCH] Add JS test runner that runs tests right in the browser --- tests/src/test/js/frame.js | 6 +- tools/core/pom.xml | 6 + .../deobfuscate/js}/DeobfuscateFunction.java | 4 +- .../tooling/deobfuscate/js}/Deobfuscator.java | 70 ++-- .../deobfuscate/js}/DeobfuscatorCallback.java | 4 +- .../deobfuscate/js/DeobfuscatorJs.java | 24 ++ .../deobfuscate/js/DeobfuscatorLib.java | 46 +++ .../teavm/tooling/deobfuscate/js}/Frame.java | 4 +- .../deobfuscate/js}/Int8ArrayInputStream.java | 4 +- tools/devserver/pom.xml | 8 +- tools/junit/pom.xml | 63 ++++ .../org/teavm/junit/BrowserRunStrategy.java | 346 ++++++++++++++++++ .../java/org/teavm/junit/CRunStrategy.java | 8 + .../org/teavm/junit/HtmlUnitRunStrategy.java | 8 + .../java/org/teavm/junit/TeaVMTestRunner.java | 78 +++- .../org/teavm/junit/TestJsEntryPoint.java | 57 +++ .../java/org/teavm/junit/TestRunStrategy.java | 4 + .../main/java/org/teavm/junit/TestRunner.java | 5 + .../src/main/resources/test-server/client.js | 146 ++++++++ .../src/main/resources/test-server/frame.html | 25 ++ .../src/main/resources/test-server/frame.js | 190 ++++++++++ .../src/main/resources/test-server/index.html | 31 ++ 22 files changed, 1089 insertions(+), 48 deletions(-) rename tools/{devserver/src/main/java/org/teavm/devserver/deobfuscate => core/src/main/java/org/teavm/tooling/deobfuscate/js}/DeobfuscateFunction.java (90%) rename tools/{devserver/src/main/java/org/teavm/devserver/deobfuscate => core/src/main/java/org/teavm/tooling/deobfuscate/js}/Deobfuscator.java (72%) rename tools/{devserver/src/main/java/org/teavm/devserver/deobfuscate => core/src/main/java/org/teavm/tooling/deobfuscate/js}/DeobfuscatorCallback.java (90%) create mode 100644 tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/DeobfuscatorJs.java create mode 100644 tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/DeobfuscatorLib.java rename tools/{devserver/src/main/java/org/teavm/devserver/deobfuscate => core/src/main/java/org/teavm/tooling/deobfuscate/js}/Frame.java (92%) rename tools/{devserver/src/main/java/org/teavm/devserver/deobfuscate => core/src/main/java/org/teavm/tooling/deobfuscate/js}/Int8ArrayInputStream.java (94%) create mode 100644 tools/junit/src/main/java/org/teavm/junit/BrowserRunStrategy.java create mode 100644 tools/junit/src/main/java/org/teavm/junit/TestJsEntryPoint.java create mode 100644 tools/junit/src/main/resources/test-server/client.js create mode 100644 tools/junit/src/main/resources/test-server/frame.html create mode 100644 tools/junit/src/main/resources/test-server/frame.js create mode 100644 tools/junit/src/main/resources/test-server/index.html diff --git a/tests/src/test/js/frame.js b/tests/src/test/js/frame.js index 524398d32..9bb78b08f 100644 --- a/tests/src/test/js/frame.js +++ b/tests/src/test/js/frame.js @@ -73,7 +73,7 @@ function launchTest(argument, callback) { function buildErrorMessage(e) { let stack = ""; - var je = main.javaException(e); + let je = main.javaException(e); if (je && je.constructor.$meta) { stack = je.constructor.$meta.name + ": "; stack += je.getMessage(); @@ -85,8 +85,8 @@ function launchTest(argument, callback) { } function launchWasmTest(path, argument, callback) { - var output = []; - var outputBuffer = ""; + let output = []; + let outputBuffer = ""; function putwchar(charCode) { if (charCode === 10) { diff --git a/tools/core/pom.xml b/tools/core/pom.xml index 571dd448d..cefbf1bc1 100644 --- a/tools/core/pom.xml +++ b/tools/core/pom.xml @@ -44,6 +44,12 @@ commons-io true + + org.teavm + teavm-jso-apis + ${project.version} + provided + diff --git a/tools/devserver/src/main/java/org/teavm/devserver/deobfuscate/DeobfuscateFunction.java b/tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/DeobfuscateFunction.java similarity index 90% rename from tools/devserver/src/main/java/org/teavm/devserver/deobfuscate/DeobfuscateFunction.java rename to tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/DeobfuscateFunction.java index 9bf83db4f..1cc1a0ab7 100644 --- a/tools/devserver/src/main/java/org/teavm/devserver/deobfuscate/DeobfuscateFunction.java +++ b/tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/DeobfuscateFunction.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Alexey Andreev. + * Copyright 2021 konsoletyper. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.teavm.devserver.deobfuscate; +package org.teavm.tooling.deobfuscate.js; import org.teavm.jso.JSFunctor; import org.teavm.jso.JSObject; diff --git a/tools/devserver/src/main/java/org/teavm/devserver/deobfuscate/Deobfuscator.java b/tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/Deobfuscator.java similarity index 72% rename from tools/devserver/src/main/java/org/teavm/devserver/deobfuscate/Deobfuscator.java rename to tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/Deobfuscator.java index 1bbe44924..267765c8a 100644 --- a/tools/devserver/src/main/java/org/teavm/devserver/deobfuscate/Deobfuscator.java +++ b/tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/Deobfuscator.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Alexey Andreev. + * Copyright 2021 konsoletyper. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.teavm.devserver.deobfuscate; +package org.teavm.tooling.deobfuscate.js; import java.io.IOException; import java.util.ArrayList; @@ -26,6 +26,7 @@ import org.teavm.debugging.information.SourceLocation; import org.teavm.jso.JSBody; import org.teavm.jso.ajax.XMLHttpRequest; import org.teavm.jso.core.JSArray; +import org.teavm.jso.core.JSObjects; import org.teavm.jso.core.JSRegExp; import org.teavm.jso.core.JSString; import org.teavm.jso.typedarrays.ArrayBuffer; @@ -33,9 +34,16 @@ import org.teavm.jso.typedarrays.Int8Array; import org.teavm.model.MethodReference; public final class Deobfuscator { - private static final JSRegExp FRAME_PATTERN = JSRegExp.create("^ +at ([^(]+) *\\((.+):([0-9]+):([0-9]+)\\) *$"); + private static final JSRegExp FRAME_PATTERN = JSRegExp.create("" + + "(^ +at ([^(]+) *\\((.+):([0-9]+):([0-9]+)\\) *$)|" + + "(^([^@]*)@(.+):([0-9]+):([0-9]+)$)"); + private DebugInformation debugInformation; + private String classesFileName; - private Deobfuscator() { + public Deobfuscator(ArrayBuffer buffer, String classesFileName) throws IOException { + Int8Array array = Int8Array.create(buffer); + debugInformation = DebugInformation.read(new Int8ArrayInputStream(array)); + this.classesFileName = classesFileName; } public static void main(String[] args) { @@ -52,37 +60,43 @@ public final class Deobfuscator { xhr.send(); } + public Frame[] deobfuscate(String stack) { + List frames = new ArrayList<>(); + for (String line : splitLines(stack)) { + JSArray groups = FRAME_PATTERN.exec(JSString.valueOf(line)); + if (groups == null) { + continue; + } + + int groupOffset = 1; + if (JSObjects.isUndefined(groups.get(1))) { + groupOffset = 6; + } + + String functionName = groups.get(1 + groupOffset).stringValue(); + String fileName = groups.get(2 + groupOffset).stringValue(); + int lineNumber = Integer.parseInt(groups.get(3 + groupOffset).stringValue()); + int columnNumber = Integer.parseInt(groups.get(4 + groupOffset).stringValue()); + List framesPerLine = deobfuscateFrames(debugInformation, classesFileName, fileName, + lineNumber, columnNumber); + if (framesPerLine == null) { + framesPerLine = Arrays.asList(createDefaultFrame(fileName, functionName, lineNumber)); + } + frames.addAll(framesPerLine); + } + return frames.toArray(new Frame[0]); + } + private static void installDeobfuscator(ArrayBuffer buffer, String classesFileName) { - Int8Array array = Int8Array.create(buffer); - DebugInformation debugInformation; + Deobfuscator deobfuscator; try { - debugInformation = DebugInformation.read(new Int8ArrayInputStream(array)); + deobfuscator = new Deobfuscator(buffer, classesFileName); } catch (IOException e) { e.printStackTrace(); return; } - setDeobfuscateFunction(stack -> { - List frames = new ArrayList<>(); - for (String line : splitLines(stack)) { - JSArray groups = FRAME_PATTERN.exec(JSString.valueOf(line)); - if (groups == null) { - continue; - } - - String functionName = groups.get(1).stringValue(); - String fileName = groups.get(2).stringValue(); - int lineNumber = Integer.parseInt(groups.get(3).stringValue()); - int columnNumber = Integer.parseInt(groups.get(4).stringValue()); - List framesPerLine = deobfuscateFrames(debugInformation, classesFileName, fileName, - lineNumber, columnNumber); - if (framesPerLine == null) { - framesPerLine = Arrays.asList(createDefaultFrame(fileName, functionName, lineNumber)); - } - frames.addAll(framesPerLine); - } - return frames.toArray(new Frame[0]); - }); + setDeobfuscateFunction(deobfuscator::deobfuscate); DeobfuscatorCallback callback = getCallback(); if (callback != null) { callback.run(); diff --git a/tools/devserver/src/main/java/org/teavm/devserver/deobfuscate/DeobfuscatorCallback.java b/tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/DeobfuscatorCallback.java similarity index 90% rename from tools/devserver/src/main/java/org/teavm/devserver/deobfuscate/DeobfuscatorCallback.java rename to tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/DeobfuscatorCallback.java index d79fc049b..bb9006585 100644 --- a/tools/devserver/src/main/java/org/teavm/devserver/deobfuscate/DeobfuscatorCallback.java +++ b/tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/DeobfuscatorCallback.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Alexey Andreev. + * Copyright 2021 konsoletyper. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.teavm.devserver.deobfuscate; +package org.teavm.tooling.deobfuscate.js; import org.teavm.jso.JSFunctor; import org.teavm.jso.JSObject; diff --git a/tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/DeobfuscatorJs.java b/tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/DeobfuscatorJs.java new file mode 100644 index 000000000..09d1ee872 --- /dev/null +++ b/tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/DeobfuscatorJs.java @@ -0,0 +1,24 @@ +/* + * Copyright 2021 Alexey Andreev. + * + * 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 org.teavm.tooling.deobfuscate.js; + +import org.teavm.jso.JSObject; +import org.teavm.jso.typedarrays.ArrayBuffer; + +public interface DeobfuscatorJs extends JSObject { + DeobfuscateFunction create(ArrayBuffer buffer, String classesFileName); +} + diff --git a/tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/DeobfuscatorLib.java b/tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/DeobfuscatorLib.java new file mode 100644 index 000000000..ec8ed2034 --- /dev/null +++ b/tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/DeobfuscatorLib.java @@ -0,0 +1,46 @@ +/* + * Copyright 2021 Alexey Andreev. + * + * 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 org.teavm.tooling.deobfuscate.js; + +import java.io.IOException; +import org.teavm.jso.JSBody; +import org.teavm.jso.typedarrays.ArrayBuffer; + +public final class DeobfuscatorLib implements DeobfuscatorJs { + private DeobfuscatorLib() { + } + + @Override + public DeobfuscateFunction create(ArrayBuffer buffer, String classesFileName) { + try { + return new Deobfuscator(buffer, classesFileName)::deobfuscate; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static void main(String[] args) { + install(new DeobfuscatorLib()); + } + + @JSBody(params = "instance", script = + "deobfuscator.create = function(buffer, classesFileName) {" + + "return instance.create(buffer, classesFileName);" + + "}" + ) + private static native void install(DeobfuscatorJs js); +} + diff --git a/tools/devserver/src/main/java/org/teavm/devserver/deobfuscate/Frame.java b/tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/Frame.java similarity index 92% rename from tools/devserver/src/main/java/org/teavm/devserver/deobfuscate/Frame.java rename to tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/Frame.java index 742e54041..b4a425406 100644 --- a/tools/devserver/src/main/java/org/teavm/devserver/deobfuscate/Frame.java +++ b/tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/Frame.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Alexey Andreev. + * Copyright 2021 konsoletyper. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.teavm.devserver.deobfuscate; +package org.teavm.tooling.deobfuscate.js; import org.teavm.jso.JSObject; import org.teavm.jso.JSProperty; diff --git a/tools/devserver/src/main/java/org/teavm/devserver/deobfuscate/Int8ArrayInputStream.java b/tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/Int8ArrayInputStream.java similarity index 94% rename from tools/devserver/src/main/java/org/teavm/devserver/deobfuscate/Int8ArrayInputStream.java rename to tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/Int8ArrayInputStream.java index 95481c457..a9e2f5ab6 100644 --- a/tools/devserver/src/main/java/org/teavm/devserver/deobfuscate/Int8ArrayInputStream.java +++ b/tools/core/src/main/java/org/teavm/tooling/deobfuscate/js/Int8ArrayInputStream.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 Alexey Andreev. + * Copyright 2021 Alexey Andreev. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.teavm.devserver.deobfuscate; +package org.teavm.tooling.deobfuscate.js; import java.io.InputStream; import org.teavm.jso.typedarrays.Int8Array; diff --git a/tools/devserver/pom.xml b/tools/devserver/pom.xml index e0a893d24..1cfadf889 100644 --- a/tools/devserver/pom.xml +++ b/tools/devserver/pom.xml @@ -61,12 +61,6 @@ teavm-core ${project.version} - - org.teavm - teavm-jso-apis - ${project.version} - provided - org.teavm teavm-tooling @@ -134,7 +128,7 @@ deobfuscator.js true ADVANCED - org.teavm.devserver.deobfuscate.Deobfuscator + org.teavm.tooling.deobfuscate.js.Deobfuscator $teavm_deobfuscator diff --git a/tools/junit/pom.xml b/tools/junit/pom.xml index 163ed53a8..beebf53f4 100644 --- a/tools/junit/pom.xml +++ b/tools/junit/pom.xml @@ -50,6 +50,34 @@ htmlunit 2.33 + + + org.eclipse.jetty + jetty-server + + + org.eclipse.jetty.websocket + javax-websocket-server-impl + + + org.eclipse.jetty.websocket + websocket-client + + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-databind + + + + javax.servlet + javax.servlet-api + 3.1.0 + @@ -70,6 +98,41 @@ org.apache.maven.plugins maven-javadoc-plugin + + + org.teavm + teavm-maven-plugin + ${project.version} + + + org.teavm + teavm-jso-impl + ${project.version} + + + org.teavm + teavm-classlib + ${project.version} + + + + + compile-deobfuscator + + compile + + process-classes + + ${project.build.directory}/classes/test-server + deobfuscator.js + true + ADVANCED + org.teavm.tooling.deobfuscate.js.DeobfuscatorLib + deobfuscator + + + + \ No newline at end of file diff --git a/tools/junit/src/main/java/org/teavm/junit/BrowserRunStrategy.java b/tools/junit/src/main/java/org/teavm/junit/BrowserRunStrategy.java new file mode 100644 index 000000000..660f8a63d --- /dev/null +++ b/tools/junit/src/main/java/org/teavm/junit/BrowserRunStrategy.java @@ -0,0 +1,346 @@ +/* + * Copyright 2021 Alexey Andreev. + * + * 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 org.teavm.junit; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.Reader; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import javax.servlet.ServletConfig; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.WebSocketAdapter; +import org.eclipse.jetty.websocket.api.WebSocketBehavior; +import org.eclipse.jetty.websocket.api.WebSocketPolicy; +import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; + +public class BrowserRunStrategy implements TestRunStrategy { + private boolean decodeStack = Boolean.parseBoolean(System.getProperty(TeaVMTestRunner.JS_DECODE_STACK, "true")); + private final File baseDir; + private final String type; + private final Function browserRunner; + private Process browserProcess; + private Server server; + private int port; + private AtomicInteger idGenerator = new AtomicInteger(0); + private AtomicReference wsSession = new AtomicReference<>(); + private CountDownLatch wsSessionReady = new CountDownLatch(1); + private ConcurrentMap awaitingRuns = new ConcurrentHashMap<>(); + private ObjectMapper objectMapper = new ObjectMapper(); + + public BrowserRunStrategy(File baseDir, String type, Function browserRunner) { + this.baseDir = baseDir; + this.type = type; + this.browserRunner = browserRunner; + } + + @Override + public void beforeAll() { + runServer(); + browserProcess = browserRunner.apply("http://localhost:" + port + "/index.html"); + } + + private void runServer() { + server = new Server(); + ServerConnector connector = new ServerConnector(server); + server.addConnector(connector); + + ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS); + context.setContextPath("/"); + server.setHandler(context); + + TestCodeServlet servlet = new TestCodeServlet(); + + ServletHolder servletHolder = new ServletHolder(servlet); + servletHolder.setAsyncSupported(true); + context.addServlet(servletHolder, "/*"); + + try { + server.start(); + } catch (Exception e) { + throw new RuntimeException(e); + } + port = connector.getLocalPort(); + } + + @Override + public void afterAll() { + try { + server.stop(); + } catch (Exception e) { + e.printStackTrace(); + } + if (browserProcess != null) { + browserProcess.destroy(); + } + } + + @Override + public void beforeThread() { + } + + @Override + public void afterThread() { + } + + @Override + public void runTest(TestRun run) throws IOException { + try { + while (!wsSessionReady.await(1L, TimeUnit.SECONDS)) { + // keep waiting + } + } catch (InterruptedException e) { + run.getCallback().error(e); + return; + } + + Session ws = wsSession.get(); + if (ws == null) { + return; + } + int id = idGenerator.incrementAndGet(); + awaitingRuns.put(id, run); + + JsonNodeFactory nf = objectMapper.getNodeFactory(); + ObjectNode node = nf.objectNode(); + node.set("id", nf.numberNode(id)); + + ArrayNode array = nf.arrayNode(); + node.set("tests", array); + + File file = new File(run.getBaseDirectory(), run.getFileName()).getAbsoluteFile(); + String relPath = baseDir.getAbsoluteFile().toPath().relativize(file.toPath()).toString(); + ObjectNode testNode = nf.objectNode(); + testNode.set("type", nf.textNode(type)); + testNode.set("name", nf.textNode(run.getFileName())); + testNode.set("file", nf.textNode("tests/" + relPath)); + if (run.getArgument() != null) { + testNode.set("argument", nf.textNode(run.getArgument())); + } + array.add(testNode); + + String message = node.toString(); + ws.getRemote().sendStringByFuture(message); + } + + class TestCodeServlet extends HttpServlet { + private WebSocketServletFactory wsFactory; + private Map contentCache = new ConcurrentHashMap<>(); + + @Override + public void init(ServletConfig config) throws ServletException { + super.init(config); + WebSocketPolicy wsPolicy = new WebSocketPolicy(WebSocketBehavior.SERVER); + wsFactory = WebSocketServletFactory.Loader.load(config.getServletContext(), wsPolicy); + wsFactory.setCreator((req, resp) -> new TestCodeSocket()); + try { + wsFactory.start(); + } catch (Exception e) { + throw new ServletException(e); + } + } + + @Override + protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + String path = req.getRequestURI(); + if (path != null) { + if (!path.startsWith("/")) { + path = "/" + path; + } + if (req.getMethod().equals("GET")) { + switch (path) { + case "/index.html": + case "/frame.html": { + String content = getFromCache(path, "true".equals(req.getParameter("logging"))); + if (content != null) { + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("text/html"); + resp.getOutputStream().write(content.getBytes(StandardCharsets.UTF_8)); + resp.getOutputStream().flush(); + return; + } + } + case "/client.js": + case "/frame.js": + case "/deobfuscator.js": { + String content = getFromCache(path, false); + if (content != null) { + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("application/javascript"); + resp.getOutputStream().write(content.getBytes(StandardCharsets.UTF_8)); + resp.getOutputStream().flush(); + return; + } + } + } + if (path.startsWith("/tests/")) { + String relPath = path.substring("/tests/".length()); + File file = new File(baseDir, relPath); + if (file.isFile()) { + resp.setStatus(HttpServletResponse.SC_OK); + if (file.getName().endsWith(".js")) { + resp.setContentType("application/javascript"); + } else if (file.getName().endsWith(".wasm")) { + resp.setContentType("application/wasm"); + } + try (FileInputStream input = new FileInputStream(file)) { + copy(input, resp.getOutputStream()); + } + resp.getOutputStream().flush(); + } + } + } + if (path.equals("/ws") && wsFactory.isUpgradeRequest(req, resp) + && (wsFactory.acceptWebSocket(req, resp) || resp.isCommitted())) { + return; + } + } + + resp.setStatus(HttpServletResponse.SC_NOT_FOUND); + } + + private String getFromCache(String fileName, boolean logging) { + return contentCache.computeIfAbsent(fileName, fn -> { + ClassLoader loader = BrowserRunStrategy.class.getClassLoader(); + try (InputStream input = loader.getResourceAsStream("test-server" + fn); + Reader reader = new InputStreamReader(input)) { + StringBuilder sb = new StringBuilder(); + char[] buffer = new char[2048]; + while (true) { + int charsRead = reader.read(buffer); + if (charsRead < 0) { + break; + } + sb.append(buffer, 0, charsRead); + } + return sb.toString() + .replace("{{PORT}}", String.valueOf(port)) + .replace("\"{{LOGGING}}\"", String.valueOf(logging)) + .replace("\"{{DEOBFUSCATION}}\"", String.valueOf(decodeStack)); + } catch (IOException e) { + e.printStackTrace(); + return null; + } + }); + } + + private void copy(InputStream input, OutputStream output) throws IOException { + byte[] buffer = new byte[2048]; + while (true) { + int bytes = input.read(buffer); + if (bytes < 0) { + break; + } + output.write(buffer, 0, bytes); + } + } + } + + class TestCodeSocket extends WebSocketAdapter { + private AtomicBoolean ready = new AtomicBoolean(false); + + @Override + public void onWebSocketConnect(Session sess) { + if (wsSession.compareAndSet(null, sess)) { + ready.set(true); + wsSessionReady.countDown(); + } else { + System.err.println("Link opened in multiple browsers"); + } + } + + @Override + public void onWebSocketClose(int statusCode, String reason) { + if (ready.get()) { + System.err.println("Browser has disconnected"); + for (TestRun run : awaitingRuns.values()) { + run.getCallback().error(new RuntimeException("Browser disconnected unexpectedly")); + } + } + } + + @Override + public void onWebSocketText(String message) { + if (!ready.get()) { + return; + } + + JsonNode node; + try { + node = objectMapper.readTree(new StringReader(message)); + } catch (IOException e) { + throw new RuntimeException(e); + } + + int id = node.get("id").asInt(); + TestRun run = awaitingRuns.remove(id); + if (run == null) { + System.err.println("Unexpected run id: " + id); + return; + } + + JsonNode resultNode = node.get("result"); + + JsonNode log = resultNode.get("log"); + if (log != null) { + for (JsonNode logEntry : log) { + String str = logEntry.get("message").asText(); + switch (logEntry.get("type").asText()) { + case "stdout": + System.out.println(str); + break; + case "stderr": + System.err.println(str); + break; + } + } + } + + String status = resultNode.get("status").asText(); + if (status.equals("OK")) { + run.getCallback().complete(); + } else { + run.getCallback().error(new RuntimeException(resultNode.get("errorMessage").asText())); + } + } + } +} diff --git a/tools/junit/src/main/java/org/teavm/junit/CRunStrategy.java b/tools/junit/src/main/java/org/teavm/junit/CRunStrategy.java index aff1afcad..89b583893 100644 --- a/tools/junit/src/main/java/org/teavm/junit/CRunStrategy.java +++ b/tools/junit/src/main/java/org/teavm/junit/CRunStrategy.java @@ -33,6 +33,14 @@ class CRunStrategy implements TestRunStrategy { this.compilerCommand = compilerCommand; } + @Override + public void beforeAll() { + } + + @Override + public void afterAll() { + } + @Override public void beforeThread() { } diff --git a/tools/junit/src/main/java/org/teavm/junit/HtmlUnitRunStrategy.java b/tools/junit/src/main/java/org/teavm/junit/HtmlUnitRunStrategy.java index 8bf7fe430..eff3c9620 100644 --- a/tools/junit/src/main/java/org/teavm/junit/HtmlUnitRunStrategy.java +++ b/tools/junit/src/main/java/org/teavm/junit/HtmlUnitRunStrategy.java @@ -41,6 +41,14 @@ class HtmlUnitRunStrategy implements TestRunStrategy { private ThreadLocal page = new ThreadLocal<>(); private int runs; + @Override + public void beforeAll() { + } + + @Override + public void afterAll() { + } + @Override public void beforeThread() { init(); diff --git a/tools/junit/src/main/java/org/teavm/junit/TeaVMTestRunner.java b/tools/junit/src/main/java/org/teavm/junit/TeaVMTestRunner.java index 20b8ef98d..0485c86f1 100644 --- a/tools/junit/src/main/java/org/teavm/junit/TeaVMTestRunner.java +++ b/tools/junit/src/main/java/org/teavm/junit/TeaVMTestRunner.java @@ -17,10 +17,12 @@ package org.teavm.junit; import static java.nio.charset.StandardCharsets.UTF_8; import java.io.BufferedOutputStream; +import java.io.BufferedReader; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; @@ -164,7 +166,12 @@ public class TeaVMTestRunner extends Runner implements Filterable { case "htmlunit": jsRunStrategy = new HtmlUnitRunStrategy(); break; - case "": + case "browser": + jsRunStrategy = new BrowserRunStrategy(outputDir, "JAVASCRIPT", this::customBrowser); + break; + case "browser-chrome": + jsRunStrategy = new BrowserRunStrategy(outputDir, "JAVASCRIPT", this::chromeBrowser); + break; case "none": jsRunStrategy = null; break; @@ -180,6 +187,73 @@ public class TeaVMTestRunner extends Runner implements Filterable { } } + private Process customBrowser(String url) { + System.out.println("Open link to run tests: " + url + "?logging=true"); + return null; + } + + private Process chromeBrowser(String url) { + File temp; + try { + temp = File.createTempFile("teavm", "teavm"); + temp.delete(); + temp.mkdirs(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + deleteDir(temp); + })); + System.out.println("Running chrome with user data dir: " + temp.getAbsolutePath()); + ProcessBuilder pb = new ProcessBuilder( + "google-chrome-stable", + "--headless", + "--disable-gpu", + "--remote-debugging-port=9222", + "--no-first-run", + "--user-data-dir=" + temp.getAbsolutePath(), + url + ); + Process process = pb.start(); + logStream(process.getInputStream(), "Chrome stdout"); + logStream(process.getErrorStream(), "Chrome stderr"); + new Thread(() -> { + try { + System.out.println("Chrome process terminated with code: " + process.waitFor()); + } catch (InterruptedException e) { + // ignore + } + }); + return process; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private void logStream(InputStream stream, String name) { + new Thread(() -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) { + while (true) { + String line = reader.readLine(); + if (line == null) { + break; + } + System.out.println(name + ": " + line); + } + } catch (IOException e) { + e.printStackTrace(); + } + }).start(); + } + + private void deleteDir(File dir) { + for (File file : dir.listFiles()) { + if (file.isDirectory()) { + deleteDir(file); + } else { + file.delete(); + } + } + dir.delete(); + } + @Override public Description getDescription() { if (suiteDescription == null) { @@ -707,7 +781,7 @@ public class TeaVMTestRunner extends Runner implements Filterable { } }; } - return compile(configuration, targetSupplier, TestEntryPoint.class.getName(), path, ".js", + return compile(configuration, targetSupplier, TestJsEntryPoint.class.getName(), path, ".js", postBuild, false, additionalProcessing, baseName); } diff --git a/tools/junit/src/main/java/org/teavm/junit/TestJsEntryPoint.java b/tools/junit/src/main/java/org/teavm/junit/TestJsEntryPoint.java new file mode 100644 index 000000000..156d66f3e --- /dev/null +++ b/tools/junit/src/main/java/org/teavm/junit/TestJsEntryPoint.java @@ -0,0 +1,57 @@ +/* + * Copyright 2021 Alexey Andreev. + * + * 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 org.teavm.junit; + +import org.teavm.jso.JSBody; + +final class TestJsEntryPoint { + private TestJsEntryPoint() { + } + + public static void main(String[] args) throws Throwable { + try { + TestEntryPoint.run(args.length > 0 ? args[0] : null); + } catch (Throwable e) { + StringBuilder sb = new StringBuilder(); + printStackTrace(e, sb); + saveJavaException(sb.toString()); + throw e; + } + } + + private static void printStackTrace(Throwable e, StringBuilder stream) { + stream.append(e.getClass().getName()); + String message = e.getLocalizedMessage(); + if (message != null) { + stream.append(": " + message); + } + stream.append("\n"); + StackTraceElement[] stackTrace = e.getStackTrace(); + if (stackTrace != null) { + for (StackTraceElement element : stackTrace) { + stream.append("\tat "); + stream.append(element).append("\n"); + } + } + if (e.getCause() != null && e.getCause() != e) { + stream.append("Caused by: "); + printStackTrace(e.getCause(), stream); + } + } + + @JSBody(params = "e", script = "window.teavmException = e") + private static native void saveJavaException(String e); +} diff --git a/tools/junit/src/main/java/org/teavm/junit/TestRunStrategy.java b/tools/junit/src/main/java/org/teavm/junit/TestRunStrategy.java index b91f0d037..350ea9861 100644 --- a/tools/junit/src/main/java/org/teavm/junit/TestRunStrategy.java +++ b/tools/junit/src/main/java/org/teavm/junit/TestRunStrategy.java @@ -18,6 +18,10 @@ package org.teavm.junit; import java.io.IOException; interface TestRunStrategy { + void beforeAll(); + + void afterAll(); + void beforeThread(); void afterThread(); diff --git a/tools/junit/src/main/java/org/teavm/junit/TestRunner.java b/tools/junit/src/main/java/org/teavm/junit/TestRunner.java index 4a0a6b4de..594521f7d 100644 --- a/tools/junit/src/main/java/org/teavm/junit/TestRunner.java +++ b/tools/junit/src/main/java/org/teavm/junit/TestRunner.java @@ -37,6 +37,11 @@ class TestRunner { public void init() { latch = new CountDownLatch(numThreads); + strategy.beforeAll(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + strategy.afterAll(); + })); + for (int i = 0; i < numThreads; ++i) { Thread thread = new Thread(() -> { strategy.beforeThread(); diff --git a/tools/junit/src/main/resources/test-server/client.js b/tools/junit/src/main/resources/test-server/client.js new file mode 100644 index 000000000..fd3fb67e5 --- /dev/null +++ b/tools/junit/src/main/resources/test-server/client.js @@ -0,0 +1,146 @@ +/* + * Copyright 2021 Alexey Andreev. + * + * 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. + */ + +"use strict"; + +let logging = false; +let deobfuscation = false; +deobfuscator(); + +function tryConnect() { + let ws = new WebSocket("ws://localhost:{{PORT}}/ws"); + + ws.onopen = () => { + if (logging) { + console.log("Connection established"); + } + listen(ws); + }; + + ws.onclose = () => { + ws.close(); + setTimeout(() => { + tryConnect(); + }, 500); + }; + + ws.onerror = err => { + if (logging) { + console.log("Could not connect WebSocket", err); + } + } +} + +function listen(ws) { + ws.onmessage = (event) => { + let request = JSON.parse(event.data); + if (logging) { + console.log("Request #" + request.id + " received"); + } + runTests(ws, request.id, request.tests, 0); + } +} + +function runTests(ws, suiteId, tests, index) { + if (index === tests.length) { + return; + } + let test = tests[index]; + runSingleTest(test, result => { + if (logging) { + console.log("Sending response #" + suiteId); + } + ws.send(JSON.stringify({ + id: suiteId, + index: index, + result: result + })); + runTests(ws, suiteId, tests, index + 1); + }); +} + +let lastDeobfuscator = null; +let lastDeobfuscatorFile = null; +let lastDeobfuscatorPromise = null; +function runSingleTest(test, callback) { + if (logging) { + console.log("Running test " + test.name); + } + if (deobfuscation) { + const fileName = test.file + ".teavmdbg"; + if (lastDeobfuscatorFile === fileName) { + if (lastDeobfuscatorPromise === null) { + runSingleTestWithDeobfuscator(test, lastDeobfuscator, callback); + } else { + lastDeobfuscatorPromise.then(value => { + runSingleTestWithDeobfuscator(test, value, callback); + }) + } + } else { + lastDeobfuscatorFile = fileName; + lastDeobfuscator = null; + const xhr = new XMLHttpRequest(); + xhr.responseType = "arraybuffer"; + lastDeobfuscatorPromise = new Promise(resolve => { + xhr.onreadystatechange = () => { + if (xhr.readyState === 4) { + const newDeobfuscator = xhr.status === 200 + ? deobfuscator.create(xhr.response, "http://localhost:{{PORT}}/" + test.file) + : null; + if (lastDeobfuscatorFile === fileName) { + lastDeobfuscator = newDeobfuscator; + lastDeobfuscatorPromise = null; + } + resolve(newDeobfuscator); + runSingleTestWithDeobfuscator(test, newDeobfuscator, callback); + } + } + xhr.open("GET", fileName); + xhr.send(); + }); + + } + } else { + runSingleTestWithDeobfuscator(test, null, callback); + } +} + +function runSingleTestWithDeobfuscator(test, deobfuscator, callback) { + let iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + let handshakeListener = handshakeEvent => { + if (handshakeEvent.source !== iframe.contentWindow || handshakeEvent.data !== "ready") { + return; + } + window.removeEventListener("message", handshakeListener); + + let listener = event => { + if (event.source !== iframe.contentWindow) { + return; + } + window.removeEventListener("message", listener); + document.body.removeChild(iframe); + callback(event.data); + }; + window.addEventListener("message", listener); + + iframe.contentWindow.$rt_decodeStack = deobfuscator; + iframe.contentWindow.postMessage(test, "*"); + }; + window.addEventListener("message", handshakeListener); + iframe.src = "about:blank"; + iframe.src = "frame.html"; +} \ No newline at end of file diff --git a/tools/junit/src/main/resources/test-server/frame.html b/tools/junit/src/main/resources/test-server/frame.html new file mode 100644 index 000000000..b37b8498a --- /dev/null +++ b/tools/junit/src/main/resources/test-server/frame.html @@ -0,0 +1,25 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/tools/junit/src/main/resources/test-server/frame.js b/tools/junit/src/main/resources/test-server/frame.js new file mode 100644 index 000000000..54192b2cf --- /dev/null +++ b/tools/junit/src/main/resources/test-server/frame.js @@ -0,0 +1,190 @@ +/* + * Copyright 2021 Alexey Andreev. + * + * 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. + */ + +"use strict"; + +window.addEventListener("message", event => { + let request = event.data; + switch (request.type) { + case "JAVASCRIPT": + appendFiles([request.file], 0, () => { + launchTest(request.argument, response => { + event.source.postMessage(response, "*"); + }); + }, error => { + event.source.postMessage(wrapResponse({ status: "failed", errorMessage: error }), "*"); + }); + break; + + case "WASM": + const runtimeFile = request.file + "-runtime.js"; + appendFiles([runtimeFile], 0, () => { + launchWasmTest(request.file, equest.argument, response => { + event.source.postMessage(response, "*"); + }); + }, error => { + event.source.postMessage(wrapResponse({ status: "failed", errorMessage: error }), "*"); + }); + break; + } +}); + +function appendFiles(files, index, callback, errorCallback) { + if (index === files.length) { + callback(); + } else { + let fileName = files[index]; + let script = document.createElement("script"); + script.onload = () => { + appendFiles(files, index + 1, callback, errorCallback); + }; + script.onerror = () => { + errorCallback("failed to load script " + fileName); + }; + script.src = fileName; + document.body.appendChild(script); + } +} + +function launchTest(argument, callback) { + main(argument ? [argument] : [], result => { + if (result instanceof Error) { + callback(wrapResponse({ + status: "failed", + errorMessage: buildErrorMessage(result) + })); + } else { + callback({ status: "OK" }); + } + }); + + function buildErrorMessage(e) { + if (typeof $rt_decodeStack === "function" && typeof teavmException == "string") { + return teavmException; + } + let stack = ""; + let je = main.javaException(e); + if (je && je.constructor.$meta) { + stack = je.constructor.$meta.name + ": "; + stack += je.getMessage(); + stack += "\n"; + } + stack += e.stack; + return stack; + } +} + +function launchWasmTest(path, argument, callback) { + let output = []; + let outputBuffer = ""; + + function putwchar(charCode) { + if (charCode === 10) { + switch (outputBuffer) { + case "SUCCESS": + callback(wrapResponse({ status: "OK" })); + break; + case "FAILURE": + callback(wrapResponse({ + status: "failed", + errorMessage: output.join("\n") + })); + break; + default: + output.push(outputBuffer); + outputBuffer = ""; + } + } else { + outputBuffer += String.fromCharCode(charCode); + } + } + + TeaVM.wasm.run(path, { + installImports: function(o) { + o.teavm.putwchar = putwchar; + }, + errorCallback: function(err) { + callback(wrapResponse({ + status: "failed", + errorMessage: err.message + '\n' + err.stack + })); + } + }); +} + +function start() { + window.parent.postMessage("ready", "*"); +} + +let log = []; + +function wrapResponse(response) { + if (log.length > 0) { + response.log = log; + log = []; + } + return response; +} + +let $rt_putStdoutCustom = createOutputFunction(msg => { + log.push({ type: "stdout", message: msg }); +}); +let $rt_putStderrCustom = createOutputFunction(msg => { + log.push({ type: "stderr", message: msg }); +}); + +function createOutputFunction(printFunction) { + let buffer = ""; + let utf8Buffer = 0; + let utf8Remaining = 0; + + function putCodePoint(ch) { + if (ch === 0xA) { + printFunction(buffer); + buffer = ""; + } else if (ch < 0x10000) { + buffer += String.fromCharCode(ch); + } else { + ch = (ch - 0x10000) | 0; + var hi = (ch >> 10) + 0xD800; + var lo = (ch & 0x3FF) + 0xDC00; + buffer += String.fromCharCode(hi, lo); + } + } + + return ch => { + if ((ch & 0x80) === 0) { + putCodePoint(ch); + } else if ((ch & 0xC0) === 0x80) { + if (utf8Buffer > 0) { + utf8Remaining <<= 6; + utf8Remaining |= ch & 0x3F; + if (--utf8Buffer === 0) { + putCodePoint(utf8Remaining); + } + } + } else if ((ch & 0xE0) === 0xC0) { + utf8Remaining = ch & 0x1F; + utf8Buffer = 1; + } else if ((ch & 0xF0) === 0xE0) { + utf8Remaining = ch & 0x0F; + utf8Buffer = 2; + } else if ((ch & 0xF8) === 0xF0) { + utf8Remaining = ch & 0x07; + utf8Buffer = 3; + } + }; +} \ No newline at end of file diff --git a/tools/junit/src/main/resources/test-server/index.html b/tools/junit/src/main/resources/test-server/index.html new file mode 100644 index 000000000..7d00aa01b --- /dev/null +++ b/tools/junit/src/main/resources/test-server/index.html @@ -0,0 +1,31 @@ + + + + + + + + + + + + + \ No newline at end of file