github.com/web-platform-tests/wpt.fyi@v0.0.0-20240530210107-70cf978996f1/shared/manifest.go (about) 1 // Copyright 2018 The WPT Dashboard Project. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 package shared 6 7 import ( 8 "encoding/json" 9 "errors" 10 "strings" 11 ) 12 13 // ErrInvalidManifest is the error returned when the manifest is a valid JSON 14 // but without the correct structure. 15 var ErrInvalidManifest = errors.New("invalid manifest") 16 17 // Manifest represents a JSON blob of all the WPT tests. 18 type Manifest struct { 19 Items map[string]rawManifestTrie `json:"items,omitempty"` 20 Version int `json:"version,omitempty"` 21 URLBase string `json:"url_base,omitempty"` 22 23 // Cache map containing the fully unmarshalled "items" object, only initialized when needed. 24 imap map[string]interface{} 25 } 26 27 // We use a recursive map[string]json.RawMessage structure to parse one layer 28 // at a time and only when needed (json.RawMessage stores the raw bytes). We 29 // redefine json.RawMessage to add custom methods, but that means we have to 30 // explicitly define and forward MarshalJSON/UnmarshalJSON to json.RawMessage. 31 type rawManifestTrie json.RawMessage 32 33 func (t rawManifestTrie) MarshalJSON() ([]byte, error) { 34 return json.RawMessage(t).MarshalJSON() 35 } 36 37 func (t *rawManifestTrie) UnmarshalJSON(b []byte) error { 38 return json.Unmarshal(b, (*json.RawMessage)(t)) 39 } 40 41 // FilterByPath filters all the manifest items by path prefixes. 42 func (m Manifest) FilterByPath(paths ...string) (*Manifest, error) { 43 result := &Manifest{Items: make(map[string]rawManifestTrie), Version: m.Version} 44 for _, p := range paths { 45 parts := strings.Split(strings.Trim(p, "/"), "/") 46 for testType, trie := range m.Items { 47 filtered, err := trie.FilterByPath(parts) 48 if err != nil { 49 return nil, err 50 } 51 if filtered != nil { 52 result.Items[testType] = filtered 53 } 54 } 55 } 56 return result, nil 57 } 58 59 func (m *Manifest) unmarshalAll() error { 60 if m.imap != nil { 61 return nil 62 } 63 m.imap = make(map[string]interface{}) 64 for testType, trie := range m.Items { 65 var decoded map[string]interface{} 66 if err := json.Unmarshal(trie, &decoded); err != nil { 67 return err 68 } 69 m.imap[testType] = decoded 70 } 71 return nil 72 } 73 74 func findNode(t interface{}, parts []string) interface{} { 75 if len(parts) == 0 { 76 return t 77 } 78 79 // t could be nil (e.g. if the previous part does not exist in the map), in which case casting will fail. 80 trie, ok := t.(map[string]interface{}) 81 if !ok { 82 return nil 83 } 84 return findNode(trie[parts[0]], parts[1:]) 85 } 86 87 // ContainsFile checks whether m contains a file path (including directories). 88 func (m *Manifest) ContainsFile(path string) (bool, error) { 89 if err := m.unmarshalAll(); err != nil { 90 return false, err 91 } 92 93 path = strings.Trim(path, "/") 94 if path == "" { 95 // Root directory always exists. 96 return true, nil 97 } 98 parts := strings.Split(path, "/") 99 for _, items := range m.imap { 100 if findNode(items, parts) != nil { 101 return true, nil 102 } 103 } 104 return false, nil 105 } 106 107 // ContainsTest checks whether m contains a full test URL. 108 func (m *Manifest) ContainsTest(testURL string) (bool, error) { 109 if err := m.unmarshalAll(); err != nil { 110 return false, err 111 } 112 113 // URLs in the manifest do not include the leading slash (url_base). 114 testURL = strings.TrimLeft(testURL, "/") 115 path, query := ParseTestURL(testURL) 116 parts := strings.Split(path, "/") 117 for _, trie := range m.imap { 118 leaf, ok := findNode(trie, parts).([]interface{}) 119 if !ok { 120 // Either we have not found a node (nil), or the node 121 // is not a list (i.e. not a leaf). 122 continue 123 } 124 // A leaf node represents a test file, and has at least two 125 // elements: [SHA, variants...]. 126 if len(leaf) < 2 { 127 return false, ErrInvalidManifest 128 } 129 for _, v := range leaf[1:] { 130 // variant=[url, extras...] 131 variant, ok := v.([]interface{}) 132 if !ok || len(variant) < 2 { 133 return false, ErrInvalidManifest 134 } 135 // If url is nil, then this is the "base variant" (no query). 136 if variant[0] == nil { 137 if query == "" { 138 return true, nil 139 } 140 continue 141 } 142 url, ok := variant[0].(string) 143 if !ok { 144 return false, ErrInvalidManifest 145 } 146 if url == testURL { 147 return true, nil 148 } 149 } 150 } 151 return false, nil 152 } 153 154 func (t rawManifestTrie) FilterByPath(pathParts []string) (rawManifestTrie, error) { 155 if t == nil || len(pathParts) == 0 { 156 return t, nil 157 } 158 159 // Unmarshal one more layer. 160 var expanded map[string]rawManifestTrie 161 if err := json.Unmarshal(t, &expanded); err != nil { 162 return nil, err 163 } 164 165 subT, err := expanded[pathParts[0]].FilterByPath(pathParts[1:]) 166 if subT == nil || err != nil { 167 return nil, err 168 } 169 filtered := map[string]rawManifestTrie{pathParts[0]: subT} 170 return json.Marshal(filtered) 171 } 172 173 // explosions returns a map of the exploded test suffixes by filename suffixes. 174 // https://web-platform-tests.org/writing-tests/testharness.html#multi-global-tests 175 func explosions() map[string][]string { 176 return map[string][]string{ 177 ".window.js": []string{".window.html"}, 178 ".worker.js": []string{".worker.html"}, 179 ".any.js": []string{ 180 ".any.html", 181 ".any.worker.html", 182 ".any.serviceworker.html", 183 ".any.sharedworker.html", 184 }, 185 } 186 } 187 188 // implosions returns an ordered list of test suffixes and their corresponding 189 // filename suffixes. 190 func implosions() [][]string { 191 // The order is important! We must match .any.* first. 192 return [][]string{ 193 []string{".any.html", ".any.js"}, 194 []string{".any.worker.html", ".any.js"}, 195 []string{".any.serviceworker.html", ".any.js"}, 196 []string{".any.sharedworker.html", ".any.js"}, 197 []string{".window.html", ".window.js"}, 198 []string{".worker.html", ".worker.js"}, 199 } 200 } 201 202 // ExplodePossibleRenames returns a map of equivalent renames for the given file rename. 203 func ExplodePossibleRenames(before, after string) map[string]string { 204 result := map[string]string{ 205 before: after, 206 } 207 eBefore := ExplodePossibleFilenames(before) 208 eAfter := ExplodePossibleFilenames(after) 209 if len(eBefore) == len(eAfter) { 210 for i := range eBefore { 211 result[eBefore[i]] = eAfter[i] 212 } 213 } 214 return result 215 } 216 217 // ExplodePossibleFilenames explodes the given single filename into the test names that 218 // could be created for it at runtime. 219 func ExplodePossibleFilenames(filePath string) []string { 220 for suffix, exploded := range explosions() { 221 if strings.HasSuffix(filePath, suffix) { 222 prefix := filePath[:len(filePath)-len(suffix)] 223 result := make([]string, len(exploded)) 224 for i := range exploded { 225 result[i] = prefix + exploded[i] 226 } 227 return result 228 } 229 } 230 return nil 231 } 232 233 // ParseTestURL parses a WPT test URL and returns its file path and query 234 // components. If the test is a multi-global (auto-generated) test, the 235 // function returns the underlying file name of the test. 236 // e.g. testURL="foo/bar/test.any.worker.html?variant" 237 // filepath="foo/bar/test.any.js" 238 // query="?variant" 239 func ParseTestURL(testURL string) (filePath, query string) { 240 filePath = testURL 241 if qPos := strings.Index(testURL, "?"); qPos > -1 { 242 filePath = testURL[:qPos] 243 query = testURL[qPos:] 244 } 245 for _, i := range implosions() { 246 testSuffix := i[0] 247 fileSuffix := i[1] 248 if strings.HasSuffix(filePath, testSuffix) { 249 filePath = strings.TrimSuffix(filePath, testSuffix) + fileSuffix 250 break 251 } 252 } 253 return filePath, query 254 }