001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.console.test; 018 019import java.util.*; 020import java.util.function.*; 021import java.util.regex.*; 022 023/** 024 * Test helper that checks every asset a rendered console page links (stylesheets, scripts, images, icons) actually 025 * resolves. 026 * 027 * <p> 028 * The console chrome links its assets at context-root URLs such as {@code /juneau-console/chrome.css}, so they only 029 * load if the host mounts something at those paths. Page tests that assert the hrefs cannot see a missing mount; 030 * this helper fetches each URL through whatever client the caller supplies and reports every one that does not 031 * answer {@code 200}. 032 * 033 * <p> 034 * The fetcher is a plain {@link Function} from URL to HTTP status, so the class has no dependency on a test 035 * framework or on a particular client. Failures throw {@link AssertionError}. Only same-origin URLs are checked: 036 * absolute {@code http(s):}, protocol-relative, {@code data:} and fragment-only URLs are skipped. 037 * 038 * <h5 class='section'>Example:</h5> 039 * <p class='bjava'> 040 * MockRestClient <jv>client</jv> = MockRestClient.<jsm>buildLax</jsm>(MyHost.<jk>class</jk>); 041 * String <jv>html</jv> = <jv>client</jv>.get(<js>"/page"</js>).run().getContent().asString(); 042 * AssetResolutionAssert.<jsm>assertAssetsResolve</jsm>(<jv>html</jv>, <jv>url</jv> -> <jv>client</jv>.get(<jv>url</jv>).run().getStatusCode()); 043 * </p> 044 * 045 * @since 10.0.0 046 */ 047public final class AssetResolutionAssert { 048 049 private static final Pattern TAG = Pattern.compile("<(script|img|link)\\b([^>]*)>", Pattern.CASE_INSENSITIVE); 050 private static final Pattern SRC = attr("src"); 051 private static final Pattern HREF = attr("href"); 052 private static final Pattern REL = attr("rel"); 053 private static final Set<String> ASSET_RELS = Set.of("stylesheet", "icon", "shortcut", "apple-touch-icon", "preload", "modulepreload", "manifest"); 054 055 private AssetResolutionAssert() {} 056 057 private static Pattern attr(String name) { 058 return Pattern.compile("(?<![\\w-])" + name + "\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')", Pattern.CASE_INSENSITIVE); 059 } 060 061 private static String value(Pattern p, String attrs) { 062 var m = p.matcher(attrs); 063 if (! m.find()) 064 return null; 065 return m.group(m.group(1) != null ? 1 : 2); 066 } 067 068 private static boolean isAssetLink(String attrs) { 069 var rel = value(REL, attrs); 070 if (rel == null) 071 return false; 072 for (var token : rel.toLowerCase(Locale.ROOT).split("\\s+")) 073 if (ASSET_RELS.contains(token)) 074 return true; 075 return false; 076 } 077 078 private static boolean isSameOrigin(String url) { 079 return ! (url.isEmpty() || url.startsWith("#") || url.startsWith("//") || url.matches("(?i)^[a-z][a-z0-9+.-]*:.*")); 080 } 081 082 /** 083 * Returns the same-origin asset URLs a page links, in document order and without duplicates. 084 * 085 * <p> 086 * Covers {@code <script src>}, {@code <img src>} and {@code <link href>} whose {@code rel} is a stylesheet, 087 * icon, preload or manifest. {@code &} is decoded and any fragment is dropped. 088 * 089 * @param html The rendered page. 090 * @return The asset URLs. Never <jk>null</jk>. 091 */ 092 public static List<String> assetUrls(String html) { 093 var urls = new LinkedHashSet<String>(); 094 var m = TAG.matcher(html); 095 while (m.find()) { 096 var attrs = m.group(2); 097 String url; 098 if ("link".equalsIgnoreCase(m.group(1))) 099 url = isAssetLink(attrs) ? value(HREF, attrs) : null; 100 else 101 url = value(SRC, attrs); 102 if (url == null) 103 continue; 104 url = url.replace("&", "&").trim(); 105 var hash = url.indexOf('#'); 106 if (hash > 0) 107 url = url.substring(0, hash); 108 if (isSameOrigin(url)) 109 urls.add(url); 110 } 111 return List.copyOf(urls); 112 } 113 114 /** 115 * Returns a description of every linked asset that does not answer {@code 200}. 116 * 117 * @param html The rendered page. 118 * @param statusOf Fetches a URL and returns its HTTP status code. 119 * @return One {@code "<status> <url>"} entry per failing asset, sorted; empty when every asset resolves. 120 */ 121 @SuppressWarnings({ 122 "java:S4276" // A null status signals failure, so it cannot be a ToIntFunction 123 }) 124 public static List<String> unresolved(String html, Function<String,Integer> statusOf) { 125 var bad = new TreeSet<String>(); 126 for (var url : assetUrls(html)) { 127 var status = statusOf.apply(url); 128 if (status == null || status != 200) 129 bad.add(status + " " + url); 130 } 131 return List.copyOf(bad); 132 } 133 134 /** 135 * Asserts that every asset the page links answers {@code 200}. 136 * 137 * @param html The rendered page. 138 * @param statusOf Fetches a URL and returns its HTTP status code. 139 * @throws AssertionError If any linked asset does not resolve; the message lists each one. 140 */ 141 @SuppressWarnings({ 142 "java:S4276" // A null status signals failure, so it cannot be a ToIntFunction 143 }) 144 public static void assertAssetsResolve(String html, Function<String,Integer> statusOf) { 145 assertAssetsResolve(null, html, statusOf); 146 } 147 148 /** 149 * Asserts that every asset the page links answers {@code 200}, naming the page in the failure message. 150 * 151 * @param page A label for the page (typically its path); can be <jk>null</jk>. 152 * @param html The rendered page. 153 * @param statusOf Fetches a URL and returns its HTTP status code. 154 * @throws AssertionError If any linked asset does not resolve; the message lists each one. 155 */ 156 @SuppressWarnings({ 157 "java:S4276" // A null status signals failure, so it cannot be a ToIntFunction 158 }) 159 public static void assertAssetsResolve(String page, String html, Function<String,Integer> statusOf) { 160 var bad = unresolved(html, statusOf); 161 if (! bad.isEmpty()) 162 throw new AssertionError((page == null ? "" : "Unresolved assets linked from " + page + ":\n") + String.join("\n", bad)); 163 } 164}