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  }