diff --git a/src/main/java/org/htmlunit/javascript/JavaScriptEngine.java b/src/main/java/org/htmlunit/javascript/JavaScriptEngine.java index e23266cd17..a161357202 100644 --- a/src/main/java/org/htmlunit/javascript/JavaScriptEngine.java +++ b/src/main/java/org/htmlunit/javascript/JavaScriptEngine.java @@ -82,6 +82,7 @@ import org.htmlunit.javascript.host.Window; import org.htmlunit.javascript.host.WindowOrWorkerGlobalScope; import org.htmlunit.javascript.host.dom.DOMException; +import org.htmlunit.javascript.host.fetch.Headers; import org.htmlunit.javascript.host.html.HTMLElement; import org.htmlunit.javascript.host.html.HTMLImageElement; import org.htmlunit.javascript.host.html.HTMLOptionElement; @@ -248,6 +249,7 @@ private void init(final WebWindow webWindow, final Page page, final Context cx) // TODO remove the cast URLSearchParams.NativeParamsIterator.init(scope, "URLSearchParams Iterator"); FormData.FormDataIterator.init(scope, "FormData Iterator"); + Headers.NativeHeadersIterator.init(scope, "Headers Iterator"); // strange but this is the reality for browsers // because there will be still some sites using this for browser detection the property is diff --git a/src/main/java/org/htmlunit/javascript/host/Window.java b/src/main/java/org/htmlunit/javascript/host/Window.java index 25e28dc1c9..91f42c4e2c 100644 --- a/src/main/java/org/htmlunit/javascript/host/Window.java +++ b/src/main/java/org/htmlunit/javascript/host/Window.java @@ -52,6 +52,8 @@ import org.htmlunit.TopLevelWindow; import org.htmlunit.WebAssert; import org.htmlunit.WebClient; +import org.htmlunit.WebRequest; +import org.htmlunit.WebResponse; import org.htmlunit.WebConsole; import org.htmlunit.WebWindow; import org.htmlunit.WebWindowNotFoundException; @@ -61,6 +63,7 @@ import org.htmlunit.corejs.javascript.Function; import org.htmlunit.corejs.javascript.JavaScriptException; import org.htmlunit.corejs.javascript.NativeConsole.Level; +import org.htmlunit.corejs.javascript.NativePromise; import org.htmlunit.corejs.javascript.NativeObject; import org.htmlunit.corejs.javascript.Scriptable; import org.htmlunit.corejs.javascript.ScriptableObject; @@ -110,6 +113,8 @@ import org.htmlunit.javascript.host.event.MessageEvent; import org.htmlunit.javascript.host.event.MouseEvent; import org.htmlunit.javascript.host.event.UIEvent; +import org.htmlunit.javascript.host.fetch.Request; +import org.htmlunit.javascript.host.fetch.Response; import org.htmlunit.javascript.host.html.DocumentProxy; import org.htmlunit.javascript.host.html.HTMLCollection; import org.htmlunit.javascript.host.html.HTMLDocument; @@ -304,6 +309,40 @@ public String atob(final String encodedData) { return WindowOrWorkerGlobalScopeMixin.atob(encodedData, this); } + /** + * The JavaScript function {@code fetch()}. + * @param input a string or a Request + * @param init optional init dictionary + * @return a promise resolving to the response + */ + @JsxFunction + public NativePromise fetch(final Object input, final Object init) { + try { + final VarScope scope = JavaScriptEngine.getTopCallScope(); + final Request request = + (Request) JavaScriptEngine.newObject(scope, "Request", new Object[] {input, init}); + final WebRequest webRequest = request.toWebRequest(this); + final WebResponse webResponse = getWebWindow().getWebClient().loadWebResponse(webRequest); + + final Response response = (Response) JavaScriptEngine.newObject(scope, + "Response", new Object[] {JavaScriptEngine.UNDEFINED, JavaScriptEngine.UNDEFINED}); + response.setFromWebResponse(webResponse, webRequest.getUrl().toExternalForm()); + return setupPromise(() -> response); + } + catch (final Exception e) { + return setupRejectedPromise(() -> { + final VarScope scope = JavaScriptEngine.getTopCallScope(); + final Object typeError = ScriptableObject.getProperty(scope, "TypeError"); + if (typeError instanceof Function function) { + final String message = org.htmlunit.util.StringUtils.isEmptyOrNull(e.getMessage()) + ? "Failed to fetch" : e.getMessage(); + return function.construct(Context.getCurrentContext(), scope, new Object[] {message}); + } + return e.getMessage(); + }); + } + } + /** * The JavaScript function {@code confirm}. * @param message the message diff --git a/src/main/java/org/htmlunit/javascript/host/fetch/FetchBodyMixin.java b/src/main/java/org/htmlunit/javascript/host/fetch/FetchBodyMixin.java new file mode 100644 index 0000000000..0764dcb1ed --- /dev/null +++ b/src/main/java/org/htmlunit/javascript/host/fetch/FetchBodyMixin.java @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2002-2026 Gargoyle Software Inc. + * + * 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 + * https://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.htmlunit.javascript.host.fetch; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.io.IOException; + +import org.htmlunit.corejs.javascript.Context; +import org.htmlunit.corejs.javascript.Function; +import org.htmlunit.corejs.javascript.NativeArrayBuffer; +import org.htmlunit.corejs.javascript.NativePromise; +import org.htmlunit.corejs.javascript.ScriptableObject; +import org.htmlunit.corejs.javascript.VarScope; +import org.htmlunit.corejs.javascript.json.JsonParser; +import org.htmlunit.corejs.javascript.json.JsonParser.ParseException; +import org.htmlunit.javascript.HtmlUnitScriptable; +import org.htmlunit.javascript.JavaScriptEngine; +import org.htmlunit.javascript.configuration.JsxFunction; +import org.htmlunit.javascript.configuration.JsxGetter; +import org.htmlunit.javascript.host.file.Blob; +import org.htmlunit.javascript.host.xml.FormData; + +/** + * Shared body handling for fetch Request/Response objects. + * + * @author Ronald Brill + */ +public class FetchBodyMixin extends HtmlUnitScriptable { + + private byte[] bodyBytes_ = new byte[0]; + private String contentType_; + private boolean bodyUsed_; + + @JsxGetter + public Object getBody() { + return null; + } + + @JsxGetter + public boolean isBodyUsed() { + return bodyUsed_; + } + + @JsxFunction + public NativePromise text() { + if (bodyUsed_) { + return rejectedTypeErrorPromise("Body has already been consumed."); + } + bodyUsed_ = true; + return setupPromise(() -> new String(bodyBytes_, UTF_8)); + } + + @JsxFunction + public NativePromise json() { + if (bodyUsed_) { + return rejectedTypeErrorPromise("Body has already been consumed."); + } + bodyUsed_ = true; + try { + final String json = new String(bodyBytes_, UTF_8); + final Object parsed = new JsonParser(Context.getCurrentContext(), getParentScope()).parseValue(json); + return setupPromise(() -> parsed); + } + catch (final ParseException e) { + return rejectedTypeErrorPromise(e.getMessage()); + } + } + + @JsxFunction + public NativePromise arrayBuffer() { + if (bodyUsed_) { + return rejectedTypeErrorPromise("Body has already been consumed."); + } + bodyUsed_ = true; + return setupPromise(() -> { + final NativeArrayBuffer buffer = new NativeArrayBuffer(bodyBytes_.length); + System.arraycopy(bodyBytes_, 0, buffer.getBuffer(), 0, bodyBytes_.length); + buffer.setParentScope(getParentScope()); + buffer.setPrototype(ScriptableObject.getClassPrototype(getParentScope(), buffer.getClassName())); + return buffer; + }); + } + + @JsxFunction + public NativePromise blob() { + if (bodyUsed_) { + return rejectedTypeErrorPromise("Body has already been consumed."); + } + bodyUsed_ = true; + return setupPromise(() -> { + final Blob blob = new Blob(bodyBytes_, contentType_); + blob.setParentScope(getParentScope()); + blob.setPrototype(getPrototype(Blob.class)); + return blob; + }); + } + + @JsxFunction + public NativePromise formData() { + if (bodyUsed_) { + return rejectedTypeErrorPromise("Body has already been consumed."); + } + bodyUsed_ = true; + return setupPromise(this::buildFormData); + } + + protected byte[] getBodyBytes() { + return bodyBytes_; + } + + protected String getBodyContentType() { + return contentType_; + } + + protected void setBody(final byte[] bodyBytes, final String contentType) { + bodyBytes_ = bodyBytes != null ? bodyBytes.clone() : new byte[0]; + contentType_ = contentType; + bodyUsed_ = false; + } + + protected void copyBodyTo(final FetchBodyMixin other) { + other.bodyBytes_ = bodyBytes_.clone(); + other.contentType_ = contentType_; + other.bodyUsed_ = false; + } + + protected Object createTypeErrorObject(final String message) { + final VarScope scope = ScriptableObject.getTopLevelScope(getParentScope()); + final Object typeError = ScriptableObject.getProperty(scope, "TypeError"); + if (typeError instanceof Function function) { + return function.construct(Context.getCurrentContext(), scope, new Object[] {message}); + } + return message; + } + + protected NativePromise rejectedTypeErrorPromise(final String message) { + return setupRejectedPromise(() -> createTypeErrorObject(message)); + } + + private FormData buildFormData() throws IOException { + final FormData formData = new FormData(); + formData.setParentScope(getParentScope()); + formData.setPrototype(getPrototype(FormData.class)); + formData.jsConstructor(JavaScriptEngine.UNDEFINED); + + final String contentType = contentType_ != null ? contentType_.toLowerCase() : ""; + if (contentType.startsWith("multipart/form-data;")) { + final String marker = "boundary="; + final int boundaryStart = contentType.indexOf(marker); + if (boundaryStart > -1) { + final String boundary = contentType.substring(boundaryStart + marker.length()).trim(); + final String body = new String(bodyBytes_, UTF_8); + final String[] parts = body.split("--" + boundary); + for (final String part : parts) { + final int headerEnd = part.indexOf("\r\n\r\n"); + if (headerEnd < 0) { + continue; + } + final String headers = part.substring(0, headerEnd); + final String data = part.substring(headerEnd + 4).replaceFirst("\r\n$", ""); + final String name = extractName(headers); + if (name != null) { + formData.append(name, data, JavaScriptEngine.UNDEFINED); + } + } + return formData; + } + } + + final String body = new String(bodyBytes_, UTF_8); + if (!body.isEmpty()) { + final String[] pairs = body.split("&"); + for (final String pair : pairs) { + final int equals = pair.indexOf('='); + final String name = equals < 0 ? pair : pair.substring(0, equals); + final String value = equals < 0 ? "" : pair.substring(equals + 1); + formData.append(org.htmlunit.util.UrlUtils.decode(name), + org.htmlunit.util.UrlUtils.decode(value), JavaScriptEngine.UNDEFINED); + } + } + + return formData; + } + + private static String extractName(final String headers) { + final String marker = "name=\""; + final int start = headers.indexOf(marker); + if (start < 0) { + return null; + } + final int end = headers.indexOf('"', start + marker.length()); + if (end < 0) { + return null; + } + return headers.substring(start + marker.length(), end); + } +} diff --git a/src/main/java/org/htmlunit/javascript/host/fetch/Headers.java b/src/main/java/org/htmlunit/javascript/host/fetch/Headers.java index f9600e183e..d930847b63 100644 --- a/src/main/java/org/htmlunit/javascript/host/fetch/Headers.java +++ b/src/main/java/org/htmlunit/javascript/host/fetch/Headers.java @@ -14,9 +14,31 @@ */ package org.htmlunit.javascript.host.fetch; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.htmlunit.corejs.javascript.Context; +import org.htmlunit.corejs.javascript.ES6Iterator; +import org.htmlunit.corejs.javascript.Function; +import org.htmlunit.corejs.javascript.IteratorLikeIterable; +import org.htmlunit.corejs.javascript.NativeObject; +import org.htmlunit.corejs.javascript.ScriptRuntime; +import org.htmlunit.corejs.javascript.Scriptable; +import org.htmlunit.corejs.javascript.SymbolKey; +import org.htmlunit.corejs.javascript.TopLevel; +import org.htmlunit.corejs.javascript.VarScope; import org.htmlunit.javascript.HtmlUnitScriptable; +import org.htmlunit.javascript.JavaScriptEngine; import org.htmlunit.javascript.configuration.JsxClass; import org.htmlunit.javascript.configuration.JsxConstructor; +import org.htmlunit.javascript.configuration.JsxFunction; +import org.htmlunit.javascript.configuration.JsxSymbol; +import org.htmlunit.util.NameValuePair; /** * A JavaScript object for {@code Headers}. @@ -27,11 +49,298 @@ @JsxClass public class Headers extends HtmlUnitScriptable { + /** Constant used to register the prototype in the context. */ + public static final String HEADERS_TAG = "Headers"; + + private final List headers_ = new ArrayList<>(); + + /** + * {@link ES6Iterator} implementation for js support. + */ + public static final class NativeHeadersIterator extends ES6Iterator { + enum Type { KEYS, VALUES, BOTH } + + private final Type type_; + private final String className_; + private final transient Iterator iterator_; + + /** + * Init. + * @param scope the scope + * @param className the class name + */ + public static void init(final TopLevel scope, final String className) { + ES6Iterator.init(scope, false, new NativeHeadersIterator(className), HEADERS_TAG); + } + + /** + * Ctor. + * @param className the class name + */ + public NativeHeadersIterator(final String className) { + super(); + iterator_ = Collections.emptyIterator(); + type_ = Type.BOTH; + className_ = className; + } + + /** + * Ctor. + * @param scope the scope + * @param className the class name + * @param type the type + * @param iterator the backing iterator + */ + public NativeHeadersIterator(final VarScope scope, final String className, final Type type, + final Iterator iterator) { + super(scope, HEADERS_TAG); + iterator_ = iterator; + type_ = type; + className_ = className; + } + + @Override + public String getClassName() { + return className_; + } + + @Override + protected boolean isDone(final Context cx, final VarScope scope) { + return !iterator_.hasNext(); + } + + @Override + protected Object nextValue(final Context cx, final VarScope scope) { + final NameValuePair e = iterator_.next(); + return switch (type_) { + case KEYS -> e.getName(); + case VALUES -> e.getValue(); + case BOTH -> cx.newArray(scope, new Object[] {e.getName(), e.getValue()}); + }; + } + } + /** * JavaScript constructor. */ @JsxConstructor - public void jsConstructor() { - // nothing to do + public void jsConstructor(final Object init) { + if (init == null || JavaScriptEngine.isUndefined(init)) { + return; + } + + if (init instanceof Headers headers) { + headers_.addAll(headers.headers_); + return; + } + + if (init instanceof NativeObject object) { + for (final Map.Entry entry : object.entrySet()) { + append(JavaScriptEngine.toString(entry.getKey()), JavaScriptEngine.toString(entry.getValue())); + } + return; + } + + if (init instanceof Scriptable scriptable && hasProperty(scriptable, SymbolKey.ITERATOR)) { + final Context cx = Context.getCurrentContext(); + try (IteratorLikeIterable itr = buildIteratorLikeIterable(cx, scriptable)) { + for (final Object nameValue : itr) { + if (!(nameValue instanceof Scriptable pair)) { + throw JavaScriptEngine.typeError("The provided value cannot be converted to a sequence."); + } + if (!hasProperty(pair, SymbolKey.ITERATOR)) { + throw JavaScriptEngine.typeError("The object must have a callable @@iterator property."); + } + + try (IteratorLikeIterable pairItr = buildIteratorLikeIterable(cx, pair)) { + final Iterator iterator = pairItr.iterator(); + final Object name = iterator.hasNext() ? iterator.next() : NOT_FOUND; + final Object value = iterator.hasNext() ? iterator.next() : NOT_FOUND; + if (name == NOT_FOUND || value == NOT_FOUND || iterator.hasNext()) { + throw JavaScriptEngine.typeError("Sequence initializer must only contain pair elements."); + } + append(JavaScriptEngine.toString(name), JavaScriptEngine.toString(value)); + } + } + } + return; + } + + throw JavaScriptEngine.typeError("Failed to construct 'Headers': Invalid initializer"); + } + + /** + * Appends a header to this Headers object. + * @param name the header name + * @param value the header value + */ + @JsxFunction + public void append(final String name, final String value) { + headers_.add(new NameValuePair(normalizeName(name), JavaScriptEngine.toString(value))); + } + + /** + * Deletes all matching header values. + * @param name the header name + */ + @JsxFunction(functionName = "delete") + @Override + public void delete(final String name) { + final String normalizedName = normalizeName(name); + headers_.removeIf(entry -> normalizedName.equals(entry.getName())); + } + + /** + * Returns the first value for the header name. + * @param name the header name + * @return the value or null + */ + @JsxFunction + public String get(final String name) { + final String normalizedName = normalizeName(name); + String result = null; + for (final NameValuePair entry : headers_) { + if (normalizedName.equals(entry.getName())) { + if (result == null) { + result = entry.getValue(); + } + else { + result += ", " + entry.getValue(); + } + } + } + return result; + } + + /** + * Returns all set-cookie header values. + * @return the set-cookie values + */ + @JsxFunction + public Scriptable getSetCookie() { + final List result = new ArrayList<>(); + for (final NameValuePair entry : headers_) { + if ("set-cookie".equals(entry.getName())) { + result.add(entry.getValue()); + } + } + return JavaScriptEngine.newArray(getParentScope(), result.toArray()); + } + + /** + * Returns if this Headers contains the given name. + * @param name the header name + * @return true if this Headers contains the given name + */ + @JsxFunction + public boolean has(final String name) { + final String normalizedName = normalizeName(name); + for (final NameValuePair entry : headers_) { + if (normalizedName.equals(entry.getName())) { + return true; + } + } + return false; + } + + /** + * Sets a header value. + * @param name the header name + * @param value the value + */ + @JsxFunction + public void set(final String name, final String value) { + final String normalizedName = normalizeName(name); + delete(normalizedName); + headers_.add(new NameValuePair(normalizedName, JavaScriptEngine.toString(value))); + } + + /** + * Allows iteration through all key/value pairs. + * @param callback function to execute on each key/value pair + */ + @JsxFunction + public void forEach(final Object callback) { + if (!(callback instanceof Function fun)) { + throw JavaScriptEngine.typeError( + "Foreach callback '" + JavaScriptEngine.toString(callback) + "' is not a function"); + } + + final List entries = entriesList(); + for (final NameValuePair entry : entries) { + fun.call(Context.getCurrentContext(), getParentScope(), this, + new Object[] {entry.getValue(), entry.getName(), this}); + } + } + + /** + * Returns an iterator of key/value pairs. + * @return the iterator + */ + @JsxFunction + @JsxSymbol(symbolName = "iterator") + public ES6Iterator entries() { + return new NativeHeadersIterator(getParentScope(), + "Headers Iterator", NativeHeadersIterator.Type.BOTH, entriesList().iterator()); + } + + /** + * Returns an iterator of header keys. + * @return the iterator + */ + @JsxFunction + public ES6Iterator keys() { + return new NativeHeadersIterator(getParentScope(), + "Headers Iterator", NativeHeadersIterator.Type.KEYS, entriesList().iterator()); + } + + /** + * Returns an iterator of header values. + * @return the iterator + */ + @JsxFunction + public ES6Iterator values() { + return new NativeHeadersIterator(getParentScope(), + "Headers Iterator", NativeHeadersIterator.Type.VALUES, entriesList().iterator()); + } + + /** + * @return all request header entries for this object + */ + List entriesList() { + final Map merged = new LinkedHashMap<>(); + for (final NameValuePair entry : headers_) { + final String previous = merged.get(entry.getName()); + if (previous == null) { + merged.put(entry.getName(), entry.getValue()); + } + else { + merged.put(entry.getName(), previous + ", " + entry.getValue()); + } + } + + final List result = new ArrayList<>(merged.size()); + for (final Map.Entry entry : merged.entrySet()) { + result.add(new NameValuePair(entry.getKey(), entry.getValue())); + } + return result; + } + + void copyFrom(final Headers headers) { + headers_.clear(); + headers_.addAll(headers.headers_); + } + + void appendRaw(final String name, final String value) { + headers_.add(new NameValuePair(normalizeName(name), value)); + } + + private static IteratorLikeIterable buildIteratorLikeIterable(final Context cx, final Scriptable iterable) { + final Object iterator = ScriptRuntime.callIterator(iterable, cx, iterable.getParentScope()); + return new IteratorLikeIterable(cx, iterable.getParentScope(), iterator); + } + + private static String normalizeName(final String name) { + return JavaScriptEngine.toString(name).toLowerCase(Locale.ROOT); } } diff --git a/src/main/java/org/htmlunit/javascript/host/fetch/Request.java b/src/main/java/org/htmlunit/javascript/host/fetch/Request.java index 5ce97f3865..66776c95a3 100644 --- a/src/main/java/org/htmlunit/javascript/host/fetch/Request.java +++ b/src/main/java/org/htmlunit/javascript/host/fetch/Request.java @@ -14,9 +14,31 @@ */ package org.htmlunit.javascript.host.fetch; -import org.htmlunit.javascript.HtmlUnitScriptable; +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Locale; + +import org.htmlunit.FormEncodingType; +import org.htmlunit.HttpHeader; +import org.htmlunit.HttpMethod; +import org.htmlunit.Page; +import org.htmlunit.WebRequest; +import org.htmlunit.corejs.javascript.NativeArrayBufferView; +import org.htmlunit.corejs.javascript.Scriptable; +import org.htmlunit.corejs.javascript.ScriptableObject; +import org.htmlunit.javascript.JavaScriptEngine; import org.htmlunit.javascript.configuration.JsxClass; import org.htmlunit.javascript.configuration.JsxConstructor; +import org.htmlunit.javascript.configuration.JsxFunction; +import org.htmlunit.javascript.configuration.JsxGetter; +import org.htmlunit.javascript.host.URLSearchParams; +import org.htmlunit.javascript.host.Window; +import org.htmlunit.javascript.host.file.Blob; +import org.htmlunit.javascript.host.html.HTMLDocument; +import org.htmlunit.javascript.host.xml.FormData; +import org.htmlunit.javascript.host.xml.XMLDocument; /** * A JavaScript object for {@code Request}. @@ -25,13 +47,298 @@ * @author Ronald Brill */ @JsxClass -public class Request extends HtmlUnitScriptable { +public class Request extends FetchBodyMixin { + + private String url_; + private String method_ = HttpMethod.GET.name(); + private Headers headers_; + private String mode_ = "cors"; + private String credentials_ = "same-origin"; + private String cache_ = "default"; + private String redirect_ = "follow"; + private String referrer_ = "about:client"; + private String referrerPolicy_ = ""; + private String integrity_ = ""; + private boolean keepalive_; + private Object signal_; + private Object body_; /** * JavaScript constructor. */ @JsxConstructor - public void jsConstructor() { - // nothing to do + public void jsConstructor(final Object input, final Object init) { + headers_ = newHeaders(); + signal_ = JavaScriptEngine.UNDEFINED; + + if (input instanceof Request request) { + copyFrom(request); + } + else { + final String inputString = JavaScriptEngine.toString(input); + url_ = resolveUrl(inputString); + } + + if (init instanceof Scriptable scriptable) { + applyInit(scriptable); + } + } + + @JsxGetter + public String getUrl() { + return url_; + } + + @JsxGetter + public String getMethod() { + return method_; + } + + @JsxGetter + public Headers getHeaders() { + return headers_; + } + + @JsxGetter + public String getMode() { + return mode_; + } + + @JsxGetter + public String getCredentials() { + return credentials_; + } + + @JsxGetter + public String getCache() { + return cache_; + } + + @JsxGetter + public String getRedirect() { + return redirect_; + } + + @JsxGetter + public String getReferrer() { + return referrer_; + } + + @JsxGetter + public String getReferrerPolicy() { + return referrerPolicy_; + } + + @JsxGetter + public String getIntegrity() { + return integrity_; + } + + @JsxGetter + public boolean isKeepalive() { + return keepalive_; + } + + @JsxGetter + public Object getSignal() { + return signal_; + } + + @Override + @JsxGetter + public Object getBody() { + return body_; + } + + @JsxFunction + public Request clone() { + final Request clone = new Request(); + clone.setParentScope(getParentScope()); + clone.setPrototype(getPrototype(Request.class)); + clone.copyFrom(this); + return clone; + } + + public WebRequest toWebRequest(final Window window) throws MalformedURLException { + final URL requestUrl = new URL(url_); + final String protocol = requestUrl.getProtocol(); + if (!"http".equals(protocol) && !"https".equals(protocol) + && !"data".equals(protocol) && !"blob".equals(protocol)) { + throw JavaScriptEngine.typeError("Failed to fetch"); + } + + final WebRequest webRequest = new WebRequest(requestUrl, + getBrowserVersion().getXmlHttpRequestAcceptHeader(), + getBrowserVersion().getAcceptEncodingHeader()); + webRequest.setCharset(UTF_8); + webRequest.setDefaultResponseContentCharset(UTF_8); + + final Page page = window.getWebWindow().getEnclosedPage(); + if (page != null) { + webRequest.setRefererHeader(page.getUrl()); + } + + webRequest.setHttpMethod(HttpMethod.valueOf(method_)); + for (final org.htmlunit.util.NameValuePair entry : headers_.entriesList()) { + webRequest.setAdditionalHeader(entry.getName(), entry.getValue()); + } + + applyBodyToWebRequest(webRequest); + return webRequest; + } + + private void copyFrom(final Request request) { + url_ = request.url_; + method_ = request.method_; + headers_ = newHeaders(); + headers_.copyFrom(request.headers_); + mode_ = request.mode_; + credentials_ = request.credentials_; + cache_ = request.cache_; + redirect_ = request.redirect_; + referrer_ = request.referrer_; + referrerPolicy_ = request.referrerPolicy_; + integrity_ = request.integrity_; + keepalive_ = request.keepalive_; + signal_ = request.signal_; + body_ = request.body_; + request.copyBodyTo(this); + } + + private Headers newHeaders() { + final Headers headers = new Headers(); + headers.setParentScope(getParentScope()); + headers.setPrototype(getPrototype(Headers.class)); + headers.jsConstructor(JavaScriptEngine.UNDEFINED); + return headers; + } + + private void applyInit(final Scriptable init) { + final Object method = ScriptableObject.getProperty(init, "method"); + if (!JavaScriptEngine.isUndefined(method)) { + final String methodString = JavaScriptEngine.toString(method).toUpperCase(Locale.ROOT); + HttpMethod.validateHttpMethodName(methodString); + method_ = methodString; + } + + final Object headers = ScriptableObject.getProperty(init, "headers"); + if (!JavaScriptEngine.isUndefined(headers)) { + headers_ = newHeaders(); + headers_.jsConstructor(headers); + } + + final Object body = ScriptableObject.getProperty(init, "body"); + if (!JavaScriptEngine.isUndefined(body) && body != null) { + if (HttpMethod.GET.name().equals(method_) || HttpMethod.HEAD.name().equals(method_)) { + throw JavaScriptEngine.typeError("Request with GET/HEAD method cannot have body."); + } + body_ = body; + if (body instanceof Blob blob) { + setBody(blob.getBytes(), blob.getType()); + } + else if (body instanceof URLSearchParams params) { + setBody(params.jsToString().getBytes(UTF_8), "application/x-www-form-urlencoded"); + } + else if (body instanceof NativeArrayBufferView view) { + setBody(view.getBuffer().getBuffer(), null); + } + else { + setBody(JavaScriptEngine.toString(body).getBytes(UTF_8), null); + } + } + + final Object mode = ScriptableObject.getProperty(init, "mode"); + if (!JavaScriptEngine.isUndefined(mode)) { + mode_ = JavaScriptEngine.toString(mode); + } + final Object credentials = ScriptableObject.getProperty(init, "credentials"); + if (!JavaScriptEngine.isUndefined(credentials)) { + credentials_ = JavaScriptEngine.toString(credentials); + } + final Object cache = ScriptableObject.getProperty(init, "cache"); + if (!JavaScriptEngine.isUndefined(cache)) { + cache_ = JavaScriptEngine.toString(cache); + } + final Object redirect = ScriptableObject.getProperty(init, "redirect"); + if (!JavaScriptEngine.isUndefined(redirect)) { + redirect_ = JavaScriptEngine.toString(redirect); + } + final Object referrer = ScriptableObject.getProperty(init, "referrer"); + if (!JavaScriptEngine.isUndefined(referrer)) { + referrer_ = JavaScriptEngine.toString(referrer); + } + final Object referrerPolicy = ScriptableObject.getProperty(init, "referrerPolicy"); + if (!JavaScriptEngine.isUndefined(referrerPolicy)) { + referrerPolicy_ = JavaScriptEngine.toString(referrerPolicy); + } + final Object integrity = ScriptableObject.getProperty(init, "integrity"); + if (!JavaScriptEngine.isUndefined(integrity)) { + integrity_ = JavaScriptEngine.toString(integrity); + } + final Object keepalive = ScriptableObject.getProperty(init, "keepalive"); + if (!JavaScriptEngine.isUndefined(keepalive)) { + keepalive_ = JavaScriptEngine.toBoolean(keepalive); + } + final Object signal = ScriptableObject.getProperty(init, "signal"); + if (!JavaScriptEngine.isUndefined(signal)) { + signal_ = signal; + } + } + + private String resolveUrl(final String urlString) { + try { + final Page page = getWindow().getWebWindow().getEnclosedPage(); + if (page instanceof org.htmlunit.html.HtmlPage htmlPage) { + return htmlPage.getFullyQualifiedUrl(urlString).toExternalForm(); + } + return new URL(urlString).toExternalForm(); + } + catch (final MalformedURLException e) { + throw JavaScriptEngine.typeError("Failed to construct 'Request': Invalid URL"); + } + } + + private void applyBodyToWebRequest(final WebRequest webRequest) { + if (body_ == null || JavaScriptEngine.isUndefined(body_)) { + return; + } + + if (body_ instanceof FormData formData) { + formData.fillRequest(webRequest); + return; + } + if (body_ instanceof URLSearchParams params) { + params.fillRequest(webRequest); + webRequest.addHint(WebRequest.HttpHint.IncludeCharsetInContentTypeHeader); + return; + } + if (body_ instanceof Blob blob) { + blob.fillRequest(webRequest); + return; + } + if (body_ instanceof NativeArrayBufferView view) { + webRequest.setRequestBody(new String(view.getBuffer().getBuffer(), UTF_8)); + webRequest.setEncodingType(null); + return; + } + if (body_ instanceof HTMLDocument || body_ instanceof XMLDocument) { + final String body = JavaScriptEngine.toString(body_); + webRequest.setRequestBody(body); + webRequest.setCharset(UTF_8); + if (webRequest.getAdditionalHeader(HttpHeader.CONTENT_TYPE) == null) { + webRequest.setAdditionalHeader(HttpHeader.CONTENT_TYPE, "text/plain;charset=UTF-8"); + webRequest.setEncodingType(FormEncodingType.TEXT_PLAIN); + } + return; + } + + final String body = JavaScriptEngine.toString(body_); + if (!body.isEmpty()) { + webRequest.setRequestBody(body); + webRequest.setCharset(UTF_8); + if (webRequest.getAdditionalHeader(HttpHeader.CONTENT_TYPE) == null) { + webRequest.setEncodingType(FormEncodingType.TEXT_PLAIN); + } + } } } diff --git a/src/main/java/org/htmlunit/javascript/host/fetch/Response.java b/src/main/java/org/htmlunit/javascript/host/fetch/Response.java index 7b55b54ec0..fc23935360 100644 --- a/src/main/java/org/htmlunit/javascript/host/fetch/Response.java +++ b/src/main/java/org/htmlunit/javascript/host/fetch/Response.java @@ -14,9 +14,20 @@ */ package org.htmlunit.javascript.host.fetch; -import org.htmlunit.javascript.HtmlUnitScriptable; +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.util.List; + +import org.htmlunit.WebResponse; +import org.htmlunit.corejs.javascript.ScriptableObject; +import org.htmlunit.corejs.javascript.VarScope; +import org.htmlunit.javascript.JavaScriptEngine; import org.htmlunit.javascript.configuration.JsxClass; import org.htmlunit.javascript.configuration.JsxConstructor; +import org.htmlunit.javascript.configuration.JsxFunction; +import org.htmlunit.javascript.configuration.JsxGetter; +import org.htmlunit.javascript.configuration.JsxStaticFunction; +import org.htmlunit.util.NameValuePair; /** * A JavaScript object for {@code Response}. @@ -25,13 +36,157 @@ * @author Ronald Brill */ @JsxClass -public class Response extends HtmlUnitScriptable { +public class Response extends FetchBodyMixin { + + private int status_ = 200; + private String statusText_ = ""; + private Headers headers_; + private String url_ = ""; + private boolean redirected_; + private String type_ = "default"; /** * JavaScript constructor. */ @JsxConstructor - public void jsConstructor() { - // nothing to do + public void jsConstructor(final Object body, final Object init) { + headers_ = newHeaders(); + if (init instanceof org.htmlunit.corejs.javascript.Scriptable scriptable) { + final Object status = ScriptableObject.getProperty(scriptable, "status"); + if (!JavaScriptEngine.isUndefined(status)) { + status_ = JavaScriptEngine.toInt32(status); + } + + final Object statusText = ScriptableObject.getProperty(scriptable, "statusText"); + if (!JavaScriptEngine.isUndefined(statusText)) { + statusText_ = JavaScriptEngine.toString(statusText); + } + + final Object headers = ScriptableObject.getProperty(scriptable, "headers"); + if (!JavaScriptEngine.isUndefined(headers)) { + headers_ = newHeaders(); + headers_.jsConstructor(headers); + } + } + setBodyFrom(body); + } + + @JsxStaticFunction + public static Response ok() { + final VarScope scope = JavaScriptEngine.getTopCallScope(); + return (Response) JavaScriptEngine.newObject(scope, "Response", new Object[] {JavaScriptEngine.UNDEFINED, + JavaScriptEngine.UNDEFINED}); + } + + @JsxStaticFunction + public static Response error() { + final Response response = ok(); + response.status_ = 0; + response.statusText_ = ""; + response.type_ = "error"; + return response; + } + + @JsxStaticFunction + public static Response redirect(final String url, final Object status) { + final Response response = ok(); + final int redirectStatus = JavaScriptEngine.isUndefined(status) ? 302 : JavaScriptEngine.toInt32(status); + response.status_ = redirectStatus; + response.headers_.set("location", JavaScriptEngine.toString(url)); + return response; + } + + @JsxGetter + public boolean isOk() { + return status_ >= 200 && status_ < 300; + } + + @JsxGetter + public int getStatus() { + return status_; + } + + @JsxGetter + public String getStatusText() { + return statusText_; + } + + @JsxGetter + public Headers getHeaders() { + return headers_; + } + + @JsxGetter + public String getUrl() { + return url_; + } + + @JsxGetter + public boolean isRedirected() { + return redirected_; + } + + @JsxGetter + public String getType() { + return type_; + } + + @JsxFunction + public Response clone() { + final Response clone = new Response(); + clone.setParentScope(getParentScope()); + clone.setPrototype(getPrototype(Response.class)); + clone.status_ = status_; + clone.statusText_ = statusText_; + clone.headers_ = clone.newHeaders(); + clone.headers_.copyFrom(headers_); + clone.url_ = url_; + clone.redirected_ = redirected_; + clone.type_ = type_; + copyBodyTo(clone); + return clone; + } + + public void setFromWebResponse(final WebResponse webResponse, final String requestUrl) { + status_ = webResponse.getStatusCode(); + statusText_ = webResponse.getStatusMessage(); + headers_ = newHeaders(); + final List responseHeaders = webResponse.getResponseHeaders(); + for (final NameValuePair header : responseHeaders) { + headers_.appendRaw(header.getName(), header.getValue()); + } + + url_ = webResponse.getWebRequest().getUrl().toExternalForm(); + redirected_ = !requestUrl.equals(url_); + type_ = "basic"; + setBody(webResponse.getContentAsBytes(), webResponse.getContentType()); + } + + private Headers newHeaders() { + final Headers headers = new Headers(); + headers.setParentScope(getParentScope()); + headers.setPrototype(getPrototype(Headers.class)); + headers.jsConstructor(JavaScriptEngine.UNDEFINED); + return headers; + } + + private void setBodyFrom(final Object body) { + if (body == null || JavaScriptEngine.isUndefined(body)) { + setBody(new byte[0], null); + return; + } + if (body instanceof org.htmlunit.javascript.host.file.Blob blob) { + setBody(blob.getBytes(), blob.getType()); + return; + } + if (body instanceof org.htmlunit.corejs.javascript.NativeArrayBufferView view) { + setBody(view.getBuffer().getBuffer(), null); + return; + } + if (body instanceof org.htmlunit.javascript.host.URLSearchParams params) { + setBody(params.jsToString().getBytes(UTF_8), "application/x-www-form-urlencoded"); + return; + } + setBody(JavaScriptEngine.toString(body).getBytes(UTF_8), null); } } diff --git a/src/test/java/org/htmlunit/javascript/host/fetch/FetchTest.java b/src/test/java/org/htmlunit/javascript/host/fetch/FetchTest.java index 23d1a3b10d..b1244aea37 100644 --- a/src/test/java/org/htmlunit/javascript/host/fetch/FetchTest.java +++ b/src/test/java/org/htmlunit/javascript/host/fetch/FetchTest.java @@ -14,22 +14,14 @@ */ package org.htmlunit.javascript.host.fetch; -import java.util.ArrayList; -import java.util.List; - -import org.htmlunit.HttpHeader; import org.htmlunit.WebDriverTestCase; -import org.htmlunit.WebRequest; import org.htmlunit.junit.annotation.Alerts; -import org.htmlunit.junit.annotation.HtmlUnitNYI; import org.htmlunit.util.MimeType; -import org.htmlunit.util.NameValuePair; import org.junit.jupiter.api.Test; import org.openqa.selenium.WebDriver; -import org.openqa.selenium.htmlunit.HtmlUnitDriver; /** - * Tests for Fetch API. + * Tests for fetch api host objects. * * @author Ronald Brill */ @@ -39,779 +31,128 @@ public class FetchTest extends WebDriverTestCase { * @throws Exception if the test fails */ @Test - @Alerts({"200", "OK", "true", "text/xml;charset=iso-8859-1", - "blah"}) - public void fetchGet() throws Exception { - final String html = DOCTYPE_HTML - + "\n" - + " \n" - + " \n" - + " \n" - + ""; - - final String xml = "blah"; - getMockWebConnection().setResponse(URL_SECOND, xml, MimeType.TEXT_XML); - - final WebDriver driver = enableFetchPolyfill(); - loadPage2(html); - verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); - - assertEquals(URL_SECOND, getMockWebConnection().getLastWebRequest().getUrl()); - } - - /** - * @throws Exception if the test fails - */ - @Test - @Alerts("TypeError") - public void fetchGetWithBody() throws Exception { + @Alerts({"function", "function", "function", "function"}) + public void globalsAvailable() throws Exception { final String html = DOCTYPE_HTML - + "\n" - + " \n" - + " \n" - + " \n" - + ""; + + " log(typeof fetch);\n" + + " log(typeof Headers);\n" + + " log(typeof Request);\n" + + " log(typeof Response);\n" + + ""; - getMockWebConnection().setResponse(URL_SECOND, "", MimeType.TEXT_XML); - - final WebDriver driver = enableFetchPolyfill(); - loadPage2(html); - verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); - - assertEquals(URL_FIRST, getMockWebConnection().getLastWebRequest().getUrl()); + loadPageVerifyTitle2(html); } /** * @throws Exception if the test fails */ @Test - @Alerts("TypeError") - public void fetchGetWrongUrl() throws Exception { + @Alerts({"200", "OK", "true", URL_SECOND + "", "basic", "payload"}) + public void fetchGet() throws Exception { final String html = DOCTYPE_HTML - + "\n" - + " \n" - + " \n" - + " \n" - + ""; - - getMockWebConnection().setResponse(URL_SECOND, "", MimeType.TEXT_XML); - - final WebDriver driver = enableFetchPolyfill(); - loadPage2(html); - verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); - - assertEquals(1, getMockWebConnection().getRequestCount()); - } - - /** - * Tests fetch with different HTTP methods. - * @throws Exception if the test fails - */ - @Test - @Alerts({"200", "OK", "true", "text/xml;charset=iso-8859-1", ""}) - public void fetchPost() throws Exception { - final String html = DOCTYPE_HTML - + "\n" - + " \n" - + " \n" - + " \n" - + ""; - - getMockWebConnection().setResponse(URL_SECOND, "", MimeType.TEXT_XML); - - final WebDriver driver = enableFetchPolyfill(); - loadPage2(html); + + " fetch('" + URL_SECOND + "')\n" + + " .then(r => {\n" + + " log(r.status);\n" + + " log(r.statusText);\n" + + " log(r.ok);\n" + + " log(r.url);\n" + + " log(r.type);\n" + + " return r.text();\n" + + " })\n" + + " .then(t => log(t))\n" + + " .catch(e => log(e.name));\n" + + ""; + + getMockWebConnection().setResponse(URL_SECOND, "payload", MimeType.TEXT_PLAIN); + final WebDriver driver = loadPage2(html); verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); - - assertEquals(URL_SECOND, getMockWebConnection().getLastWebRequest().getUrl()); - assertEquals("test data", getMockWebConnection().getLastWebRequest().getRequestBody()); } /** * @throws Exception if the test fails */ @Test - @Alerts({"200", "OK", "true", - "text/plain;charset=iso-8859-1", "bla\\sbla"}) - public void fetchGetText() throws Exception { + @Alerts({"404", "false"}) + public void fetchStatus() throws Exception { final String html = DOCTYPE_HTML - + "\n" - + " \n" - + " \n" - + " \n" - + ""; - - getMockWebConnection().setResponse(URL_SECOND, "bla bla", MimeType.TEXT_PLAIN); - - final WebDriver driver = enableFetchPolyfill(); - loadPage2(html); - verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); - - assertEquals(URL_SECOND, getMockWebConnection().getLastWebRequest().getUrl()); - } - - /** - * @throws Exception if the test fails - */ - @Test - @Alerts({"200", "OK", "true", - "application/json;charset=iso-8859-1", "{\\s'Html':\\s'Unit'\\s}"}) - public void fetchGetJsonText() throws Exception { - final String html = DOCTYPE_HTML - + "\n" - + " \n" - + " \n" - + " \n" - + ""; - - final String json = "{ 'Html': 'Unit' }"; - getMockWebConnection().setResponse(URL_SECOND, json, MimeType.APPLICATION_JSON); - - final WebDriver driver = enableFetchPolyfill(); - loadPage2(html); - verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); - - assertEquals(URL_SECOND, getMockWebConnection().getLastWebRequest().getUrl()); - } - - /** - * @throws Exception if the test fails - */ - @Test - @Alerts({"200", "OK", "true", - "application/json;charset=iso-8859-1", - "[object\\sObject]", "Unit", "{\"Html\":\"Unit\"}"}) - public void fetchGetJson() throws Exception { - final String html = DOCTYPE_HTML - + "\n" - + " \n" - + " \n" - + " \n" - + ""; - - final String json = "{ \"Html\": \"Unit\" }"; - getMockWebConnection().setResponse(URL_SECOND, json, MimeType.APPLICATION_JSON); - - final WebDriver driver = enableFetchPolyfill(); - loadPage2(html); - verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); - - assertEquals(URL_SECOND, getMockWebConnection().getLastWebRequest().getUrl()); - } - - /** - * @throws Exception if the test fails - */ - @Test - @Alerts(DEFAULT = {"200", "OK", "true", "text/plain;charset=iso-8859-1", - "[object\\sBlob]", "4", "text/plain"}, - FF = {"200", "OK", "true", "text/plain;charset=iso-8859-1", - "[object\\sBlob]", "4", "text/plain;charset=iso-8859-1"}, - FF_ESR = {"200", "OK", "true", "text/plain;charset=iso-8859-1", - "[object\\sBlob]", "4", "text/plain;charset=iso-8859-1"}) - @HtmlUnitNYI( - FF = {"200", "OK", "true", "text/plain;charset=iso-8859-1", - "[object\\sBlob]", "4", "text/plain"}, - FF_ESR = {"200", "OK", "true", "text/plain;charset=iso-8859-1", - "[object\\sBlob]", "4", "text/plain"}) - public void fetchGetBlob() throws Exception { - final String html = DOCTYPE_HTML - + "\n" - + " \n" - + " \n" - + " \n" - + ""; - - getMockWebConnection().setResponse(URL_SECOND, "ABCD", MimeType.TEXT_PLAIN); - - final WebDriver driver = enableFetchPolyfill(); - loadPage2(html); - verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); - - assertEquals(URL_SECOND, getMockWebConnection().getLastWebRequest().getUrl()); - } - - /** - * @throws Exception if the test fails - */ - @Test - @Alerts({"200", "OK", "true", "text/plain;charset=iso-8859-1", - "[object\\sArrayBuffer]", "4"}) - public void fetchGetArrayBuffer() throws Exception { - final String html = DOCTYPE_HTML - + "\n" - + " \n" - + " \n" - + " \n" - + ""; - - getMockWebConnection().setResponse(URL_SECOND, "ABCD", MimeType.TEXT_PLAIN); - - final WebDriver driver = enableFetchPolyfill(); - loadPage2(html); - verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); - - assertEquals(URL_SECOND, getMockWebConnection().getLastWebRequest().getUrl()); - } - - /** - * @throws Exception if the test fails - */ - @Test - @Alerts({"200", "OK", "true"}) - public void fetchGetCustomHeader() throws Exception { - final String html = DOCTYPE_HTML - + "\n" - + " \n" - + " \n" - + " \n" - + ""; - - final String json = "{ \"Html\": \"Unit\" }"; - getMockWebConnection().setResponse(URL_SECOND, json, MimeType.APPLICATION_JSON); - - final WebDriver driver = enableFetchPolyfill(); - loadPage2(html); - verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); - - final WebRequest lastRequest = getMockWebConnection().getLastWebRequest(); - assertEquals(URL_SECOND, lastRequest.getUrl()); - - assertEquals("x-test", lastRequest.getAdditionalHeader("X-Custom-Header")); - } - - /** - * @throws Exception if the test fails - */ - @Test - @Alerts({"200", "OK", "true", "text/plain;charset=iso-8859-1", "x-tEsT"}) - public void fetchGetCustomResponseHeader() throws Exception { - final String html = DOCTYPE_HTML - + "\n" - + " \n" - + " \n" - + " \n" - + ""; - - final List headers = new ArrayList<>(); - headers.add(new NameValuePair("X-Custom-Header", "x-tEsT")); - getMockWebConnection().setResponse(URL_SECOND, "HtmlUnit", 200, "ok", MimeType.TEXT_PLAIN, headers); + + ""; - final WebDriver driver = enableFetchPolyfill(); - loadPage2(html); + getMockWebConnection().setResponse(URL_SECOND, "missing", 404, "Not Found", MimeType.TEXT_PLAIN); + final WebDriver driver = loadPage2(html); verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); - - final WebRequest lastRequest = getMockWebConnection().getLastWebRequest(); - assertEquals(URL_SECOND, lastRequest.getUrl()); - - assertEquals("x-test", lastRequest.getAdditionalHeader("X-Custom-Header")); } /** * @throws Exception if the test fails */ @Test - @Alerts({"200", "OK", "true"}) - public void fetchPostFormData() throws Exception { + @Alerts({"200", "true", "HtmlUnit"}) + public void fetchPostJson() throws Exception { final String html = DOCTYPE_HTML - + "\n" - + " \n" - - + "
\n" - + " \n" - + "
\n" - - + " \n" - + " \n" - + ""; + + ""; getMockWebConnection().setResponse(URL_SECOND, "HtmlUnit", MimeType.TEXT_PLAIN); - - final WebDriver driver = enableFetchPolyfill(); - loadPage2(html); - verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); - - final WebRequest lastRequest = getMockWebConnection().getLastWebRequest(); - assertEquals(URL_SECOND, lastRequest.getUrl()); - - assertTrue(lastRequest.getRequestBody(), lastRequest.getRequestBody() - .contains("Content-Disposition: form-data; name=\"myText\"")); - assertTrue(lastRequest.getRequestBody(), lastRequest.getRequestBody() - .contains("HtmlUnit")); - } - - /** - * @throws Exception if the test fails - */ - @Test - @Alerts({"200", "OK", "true", "test0,Hello1\\nHello1,test1,Hello2\\nHello2"}) - public void fetchMultipartFormData() throws Exception { - final String html = DOCTYPE_HTML - + "\n" - + " \n" - + " \n" - + " \n" - + ""; - - final String boundary = "0123456789"; - final String content = "--" + boundary + "\r\n" - + "Content-Disposition: form-data; name=\"test0\"\r\n" - + "Content-Type: text/plain\r\n" - + "\r\n" - + "Hello1\nHello1\r\n" - + "--" + boundary + "\r\n" - + "Content-Disposition: form-data; name=\"test1\"\r\n" - + "Content-Type: text/plain\r\n" - + "\r\n" - + "Hello2\nHello2\r\n" - + "--" + boundary + "--"; - - getMockWebConnection().setResponse(URL_SECOND, content, "multipart/form-data; boundary=" + boundary); - - final WebDriver driver = enableFetchPolyfill(); - loadPage2(html); + final WebDriver driver = loadPage2(html); verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); - - assertEquals(URL_SECOND, getMockWebConnection().getLastWebRequest().getUrl()); } /** * @throws Exception if the test fails */ @Test - @Alerts({"200", "OK", "true"}) - public void fetchPostURLSearchParams() throws Exception { + @Alerts("TypeError") + public void fetchInvalidUrlRejects() throws Exception { final String html = DOCTYPE_HTML - + "\n" - + " \n" - + " \n" - + " \n" - + ""; - - getMockWebConnection().setResponse(URL_SECOND, "HtmlUnit", MimeType.TEXT_PLAIN); + + ""; - final WebDriver driver = enableFetchPolyfill(); - loadPage2(html); + final WebDriver driver = loadPage2(html); verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); - - final WebRequest lastRequest = getMockWebConnection().getLastWebRequest(); - assertEquals(URL_SECOND, lastRequest.getUrl()); - - String headerContentType = lastRequest.getAdditionalHeaders().get(HttpHeader.CONTENT_TYPE); - headerContentType = headerContentType.split(";")[0]; - assertEquals("application/x-www-form-urlencoded", headerContentType); - - final List params = lastRequest.getRequestParameters(); - assertEquals(2, params.size()); - assertEquals("q", params.get(0).getName()); - assertEquals("HtmlUnit", params.get(0).getValue()); - assertEquals("page", params.get(1).getName()); - assertEquals("1", params.get(1).getValue()); } /** * @throws Exception if the test fails */ @Test - @Alerts({"200", "OK", "true"}) - public void fetchPostJSON() throws Exception { + @Alerts({"200", "ok", "200"}) + public void fetchThenChainAndAwait() throws Exception { final String html = DOCTYPE_HTML - + "\n" - + " \n" - + " \n" - + " \n" - + ""; - - getMockWebConnection().setResponse(URL_SECOND, "HtmlUnit", MimeType.TEXT_PLAIN); - - final WebDriver driver = enableFetchPolyfill(); - loadPage2(html); + + ""; + + getMockWebConnection().setResponse(URL_SECOND, "ok", MimeType.TEXT_PLAIN); + final WebDriver driver = loadPage2(html); verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); - - final WebRequest lastRequest = getMockWebConnection().getLastWebRequest(); - assertEquals(URL_SECOND, lastRequest.getUrl()); - - String headerContentType = lastRequest.getAdditionalHeaders().get(HttpHeader.CONTENT_TYPE); - headerContentType = headerContentType.split(";")[0]; - assertEquals("application/json", headerContentType); - - assertEquals("{\"hello\":\"world\"}", lastRequest.getRequestBody()); - } - -// /** -// * Tests fetch with credentials. -// * @throws Exception if the test fails -// */ -// @Test -// @Alerts("ok") -// public void fetchWithCredentials() throws Exception { -// final String html = DOCTYPE_HTML -// + "\n" -// + "\n" -// + "\n" -// + ""; -// -// getMockWebConnection().setDefaultResponse("\n", MimeType.TEXT_XML); -// final WebDriver driver = loadPage2(html); -// verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); -// } -// -// /** -// * Tests fetch response cloning. -// * @throws Exception if the test fails -// */ -// @Test -// @Alerts({"true", "text1", "text2"}) -// public void fetchResponseClone() throws Exception { -// final String html = DOCTYPE_HTML -// + "\n" -// + "\n" -// + "\n" -// + ""; -// -// getMockWebConnection().setDefaultResponse("response body", MimeType.TEXT_PLAIN); -// final WebDriver driver = loadPage2(html); -// verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); -// } -// -// /** -// * Tests fetch with mode option. -// * @throws Exception if the test fails -// */ -// @Test -// @Alerts("ok") -// public void fetchWithMode() throws Exception { -// final String html = DOCTYPE_HTML -// + "\n" -// + "\n" -// + "\n" -// + ""; -// -// getMockWebConnection().setDefaultResponse("\n", MimeType.TEXT_XML); -// final WebDriver driver = loadPage2(html); -// verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); -// } -// -// /** -// * Tests fetch with cache option. -// * @throws Exception if the test fails -// */ -// @Test -// @Alerts("ok") -// public void fetchWithCache() throws Exception { -// final String html = DOCTYPE_HTML -// + "\n" -// + "\n" -// + "\n" -// + ""; -// -// getMockWebConnection().setDefaultResponse("\n", MimeType.TEXT_XML); -// final WebDriver driver = loadPage2(html); -// verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); -// } -// -// /** -// * Tests fetch response with text encoding. -// * @throws Exception if the test fails -// */ -// @Test -// @Alerts("olé") -// public void fetchResponseTextEncoding() throws Exception { -// final String html = DOCTYPE_HTML -// + "\n" -// + " \n" -// + " \n" -// + " \n" -// + " \n" -// + " \n" -// + ""; -// -// final String response = "olé"; -// final byte[] responseBytes = response.getBytes(UTF_8); -// -// getMockWebConnection().setResponse(URL_SECOND, responseBytes, 200, "OK", -// MimeType.TEXT_HTML, new ArrayList<>()); -// final WebDriver driver = loadPage2(html); -// verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); -// } - - private WebDriver enableFetchPolyfill() { - final WebDriver driver = getWebDriver(); - if (driver instanceof HtmlUnitDriver) { - ((HtmlUnitDriver) driver).getWebClient().getOptions().setFetchPolyfillEnabled(true); - } - return driver; } } diff --git a/src/test/java/org/htmlunit/javascript/host/fetch/HeadersTest.java b/src/test/java/org/htmlunit/javascript/host/fetch/HeadersTest.java new file mode 100644 index 0000000000..ad94ec2635 --- /dev/null +++ b/src/test/java/org/htmlunit/javascript/host/fetch/HeadersTest.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2002-2026 Gargoyle Software Inc. + * + * 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 + * https://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.htmlunit.javascript.host.fetch; + +import org.htmlunit.WebDriverTestCase; +import org.htmlunit.junit.annotation.Alerts; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link Headers}. + * + * @author Ronald Brill + */ +public class HeadersTest extends WebDriverTestCase { + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"0", "false"}) + public void constructorEmpty() throws Exception { + final String html = DOCTYPE_HTML + + ""; + + loadPageVerifyTitle2(html); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"v1", "v2", "v3", "v4", "v5"}) + public void constructorFromObjectSequenceAndHeaders() throws Exception { + final String html = DOCTYPE_HTML + + ""; + + loadPageVerifyTitle2(html); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"v1, v2", "true", "v3", "false"}) + public void appendSetHasDeleteCaseInsensitive() throws Exception { + final String html = DOCTYPE_HTML + + ""; + + loadPageVerifyTitle2(html); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"a=1,b=2", "a,b", "1,2", "a:1|b:2", "a=1,b=2"}) + public void iteratorsAndForEach() throws Exception { + final String html = DOCTYPE_HTML + + ""; + + loadPageVerifyTitle2(html); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"2", "c1", "c2"}) + public void getSetCookie() throws Exception { + final String html = DOCTYPE_HTML + + ""; + + loadPageVerifyTitle2(html); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts("TypeError") + public void constructorInvalid() throws Exception { + final String html = DOCTYPE_HTML + + ""; + + loadPageVerifyTitle2(html); + } +} diff --git a/src/test/java/org/htmlunit/javascript/host/fetch/RequestTest.java b/src/test/java/org/htmlunit/javascript/host/fetch/RequestTest.java new file mode 100644 index 0000000000..e5f1c783f5 --- /dev/null +++ b/src/test/java/org/htmlunit/javascript/host/fetch/RequestTest.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2002-2026 Gargoyle Software Inc. + * + * 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 + * https://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.htmlunit.javascript.host.fetch; + +import org.htmlunit.WebDriverTestCase; +import org.htmlunit.junit.annotation.Alerts; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.WebDriver; + +/** + * Tests for {@link Request}. + * + * @author Ronald Brill + */ +public class RequestTest extends WebDriverTestCase { + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"GET", URL_SECOND + "", "cors", "same-origin", "default", "follow", "about:client", "", "", "false"}) + public void constructorSimple() throws Exception { + final String html = DOCTYPE_HTML + + ""; + + loadPageVerifyTitle2(html); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"POST", "v", "false", "cors", "include", "no-cache", "manual", "", + "strict-origin", "abc", "true", "true", "body"}) + public void constructorWithInit() throws Exception { + final String html = DOCTYPE_HTML + + ""; + + final WebDriver driver = loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"POST", "x", "true", "false", "x"}) + public void constructorFromRequestAndClone() throws Exception { + final String html = DOCTYPE_HTML + + ""; + + final WebDriver driver = loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"false", "abc", "true", "TypeError"}) + public void bodyUsed() throws Exception { + final String html = DOCTYPE_HTML + + ""; + + final WebDriver driver = loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"1", "3", "3", "1", "v", "x"}) + public void bodyMethods() throws Exception { + final String html = DOCTYPE_HTML + + ""; + + final WebDriver driver = loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"TypeError", "TypeError"}) + public void invalidConstructionCases() throws Exception { + final String html = DOCTYPE_HTML + + ""; + + loadPageVerifyTitle2(html); + } +} diff --git a/src/test/java/org/htmlunit/javascript/host/fetch/ResponseTest.java b/src/test/java/org/htmlunit/javascript/host/fetch/ResponseTest.java new file mode 100644 index 0000000000..81fc4df48e --- /dev/null +++ b/src/test/java/org/htmlunit/javascript/host/fetch/ResponseTest.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2002-2026 Gargoyle Software Inc. + * + * 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 + * https://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.htmlunit.javascript.host.fetch; + +import org.htmlunit.WebDriverTestCase; +import org.htmlunit.junit.annotation.Alerts; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.WebDriver; + +/** + * Tests for {@link Response}. + * + * @author Ronald Brill + */ +public class ResponseTest extends WebDriverTestCase { + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"200", "", "true", "default", "", "false", "false"}) + public void constructorEmpty() throws Exception { + final String html = DOCTYPE_HTML + + ""; + + loadPageVerifyTitle2(html); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"201", "Created", "true", "v", "text"}) + public void constructorBodyAndInit() throws Exception { + final String html = DOCTYPE_HTML + + ""; + + final WebDriver driver = loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"object", "1", "3", "3", "v", "y"}) + public void bodyMethods() throws Exception { + final String html = DOCTYPE_HTML + + ""; + + final WebDriver driver = loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"false", "abc", "true", "TypeError"}) + public void bodyUsed() throws Exception { + final String html = DOCTYPE_HTML + + ""; + + final WebDriver driver = loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"true", "abc", "abc"}) + public void clone() throws Exception { + final String html = DOCTYPE_HTML + + ""; + + final WebDriver driver = loadPage2(html); + verifyTitle2(DEFAULT_WAIT_TIME, driver, getExpectedAlerts()); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"200", "true", "0", "error", "301", "/a", "302"}) + public void staticMethods() throws Exception { + final String html = DOCTYPE_HTML + + ""; + + loadPageVerifyTitle2(html); + } +}