tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

gentest.py (60795B)


      1 """Generates Canvas tests from YAML file definitions.
      2 
      3 See README.md for instructions on how to run this test generator and for how to
      4 add or modify tests.
      5 
      6 This code generatror was originally written by Philip Taylor for use at
      7 http://philip.html5.org/tests/canvas/suite/tests/
      8 
      9 It has been adapted for use with the Web Platform Test Suite suite at
     10 https://github.com/web-platform-tests/wpt/
     11 """
     12 
     13 from typing import Any, Callable, Container, DefaultDict, FrozenSet
     14 from typing import List, Mapping, MutableMapping, Set, Tuple, Union
     15 
     16 import re
     17 import collections
     18 import copy
     19 import dataclasses
     20 import enum
     21 import importlib
     22 import math
     23 import os
     24 import pathlib
     25 import sys
     26 import textwrap
     27 
     28 import jinja2
     29 
     30 try:
     31    import cairocffi as cairo  # type: ignore
     32 except ImportError:
     33    import cairo
     34 
     35 try:
     36    # Compatible and lots faster.
     37    import syck as yaml  # type: ignore
     38 except ImportError:
     39    import yaml
     40 
     41 
     42 class Error(Exception):
     43    """Base class for all exceptions raised by this module"""
     44 
     45 
     46 class InvalidTestDefinitionError(Error):
     47    """Raised on invalid test definition."""
     48 
     49 
     50 class _CanvasType(str, enum.Enum):
     51    HTML_CANVAS = 'HtmlCanvas'
     52    OFFSCREEN_CANVAS = 'OffscreenCanvas'
     53    WORKER = 'Worker'
     54 
     55 
     56 class _TemplateType(str, enum.Enum):
     57    REFERENCE = 'reference'
     58    HTML_REFERENCE = 'html_reference'
     59    CAIRO_REFERENCE = 'cairo_reference'
     60    IMG_REFERENCE = 'img_reference'
     61    TESTHARNESS = 'testharness'
     62 
     63 
     64 _REFERENCE_TEMPLATES = (_TemplateType.REFERENCE,
     65                        _TemplateType.HTML_REFERENCE,
     66                        _TemplateType.CAIRO_REFERENCE,
     67                        _TemplateType.IMG_REFERENCE)
     68 
     69 
     70 _TestParams = Mapping[str, Any]
     71 _MutableTestParams = MutableMapping[str, Any]
     72 
     73 
     74 # All parameters that test definitions can specify to control test generation.
     75 # Some have defaults values, used if the test definition doesn't specify them.
     76 _TEST_DEFINITION_PARAMS = {
     77    # Base parameters:
     78 
     79    # Test name, which ultimately is used as filename. File variant dimension
     80    # names (i.e. the 'file_variant_names' property below) are appended to this
     81    # to produce unique filenames.
     82    'name': None,
     83    # The implementation of the test body. This should be JavaScript code,
     84    # drawing on the canvas via the provided `canvas` and `ctx` objects.
     85    # `canvas` will be an `HTMLCanvasElement` instance for 'HtmlCanvas' test
     86    # type, else it will be an `OffscreenCanvas` instance. `ctx` will be a '2d'
     87    # rendering context.
     88    'code': None,
     89    # Whether this test variant is enabled. This can be used to generate sparse
     90    # variant grids, by making this parameter resolve to `True` only for certain
     91    # combinations of variant dimensions, e.g.:
     92    #   - enabled: {{ variant_names[1] in ['foo', 'bar'] }})
     93    'enabled': 'true',
     94    # Textual description for this test. This is used both as text in the
     95    # generated HTML page, and as testharness.js test fixture description, for
     96    # JavaScript tests (i.e. arguments to the `test()`, `async_test()` and
     97    # `promise_test()` functions).
     98    'desc': '',
     99    # If specified, 'notes:' adds a `<p>` tag in the page showing this
    100    # note.
    101    'notes': '',
    102    # Size of the canvas to use.
    103    'size': (100, 50),
    104 
    105    # Parameters controlling test runner execution:
    106 
    107    # For reftests, specifies the tolerance in pixel difference allowed for the
    108    # test to pass. By default, the test and reference must produce identical
    109    # images. To allow a certain difference, specify as, for instance:
    110    #   fuzzy: maxDifference=0-2; totalPixels=0-1234
    111    # In this example, up to 1234 pixels can differ by at most a difference of
    112    # 2 on a any color channels.
    113    'fuzzy': None,
    114    # Used to control the test timeout, in case the test takes too long to
    115    # complete. For instance: `timeout: long`.
    116    'timeout': None,
    117 
    118    # Parameters controlling what type of tests should be generated:
    119 
    120    # List of the canvas types for which the test should be generated. Defaults
    121    # to generating the test for all types. Options are:
    122    #  - 'HtmlCanvas': to test using an HTMLCanvasElement.
    123    #  - 'Offscreen': to test using an OffscreenCanvas on the main thread.
    124    #  - 'Worker': to test using an OffscreenCanvas in a worker.
    125    'canvas_types': list(_CanvasType),
    126    # Specifies the type of test fixture to generate.
    127    #
    128    # For testharness.js JavaScript tests, valid options are "sync", "async" or
    129    # "promise", which produce standard `test()`, `async_test()` and
    130    # `promise_test()` testharness fixtures respectively. For compatibility with
    131    # old tests, leaving `test_type` unspecified produces a test using the
    132    # legacy `_addTest` helper from canvas-tests.js. These are synchronous tests
    133    # that can be made asynchronous by calling `deferTest()` and calling
    134    # `t.done()` when the test completes. New tests should prefer specifying
    135    # `test_type` explicitly.
    136    #
    137    # For reftests, generated tests are by default synchronous (if 'test_type'
    138    # is not specified). Setting 'test_type' to "promise" makes it possible to
    139    # use async/await syntax in the test body. In both cases, the test completes
    140    # when the test body returns. Asynchronous APIs can be supported by wrapping
    141    # them in promises awaiting on their completion.
    142    'test_type': None,
    143    # Causes the test generator to generate a reftest instead of a JavaScript
    144    # test. Similarly to the `code:` parameter, 'reference:' should be set to
    145    # JavaScript code drawing to a provided `canvas` object via a provided `ctx`
    146    # 2D rendering context. The code generator will generate a reference HTML
    147    # file that the test runner will use to validate the test result. Cannot be
    148    # used in combination with 'html_reference:', 'cairo_reference:' or
    149    # 'img_reference:'.
    150    'reference': None,
    151    # Similar to 'reference:', but the value is an HTML document instead of
    152    # JavaScript drawing to a canvas. This is useful to use the DOM or an SVG
    153    # drawing as a reference to compare the test result against. Cannot be used
    154    # in combination with 'reference:', 'cairo_reference:' or 'img_reference:'.
    155    'html_reference': None,
    156    # Similar to 'reference:', but the value is Python code generating an image
    157    # using the pycairo library. The Python code is provided with a `surface`
    158    # and `cr` variable, instances of `cairo.ImageSurface` and `cairo.Context`
    159    # respectively. Cannot be used in combination with 'reference:',
    160    # 'html_reference:' or 'img_reference:'.
    161    'cairo_reference': None,
    162    # Similar to 'reference', but the value is the path to an image resource
    163    # file to use as reference. When using 'cairo_reference:', the generated
    164    # image path is assigned to 'img_reference:', for the template to use. A
    165    # test could technically set 'img_reference:' directly, specifying a
    166    # pre-generated image. This can be useful for plain and trivial images (e.g.
    167    # '/images/green-100x50.png', but any non-trivial pre-generated image should
    168    # be avoided because these can't easily be inspected and maintained if it
    169    # needs to be revisited in the future. Cannot be used in combination with
    170    # 'reference:', 'html_reference:' or 'cairo_reference:'.
    171    'img_reference': None,
    172 
    173    # Parameters adding HTML tags in the generated HTML files:
    174 
    175    # Additional HTML attributes to pass to the '<canvas>' tag. e.g.:
    176    #   canvas: 'style="font-size: 144px"'
    177    'canvas': None,
    178    # If specified, the 'attribute' string is used as extra parameter to
    179    # `canvas.getContext()`. For instance, using:
    180    #     attributes: '{alpha: False}'
    181    # would create a context with:
    182    #     canvas.getContext('2d', {alpha: false})
    183    'attributes': None,
    184    # List of image filenames to add `<img>` tag for. The same name is used as
    185    # id, which can be used to get the img element from the test body.
    186    'images': [],
    187    # List of image filenames to add SVG `<image>` tag for. The same name is
    188    # used as id, which can be used to get the image element from the test body.
    189    'svgimages': [],
    190    # List of custom fonts to load, by adding a `@font-face` CSS statement.
    191    # Fonts a specified by their base filename, not their full path. For
    192    # instance `fonts: ['CanvasTest']` would have the test load the font
    193    # '/fonts/CanvasTest.ttf'
    194    'fonts': [],
    195    # By default, the fonts added to the CSS via 'fonts:' are used in the test
    196    # HTML page in a hidden `<span>`, to make sure the fonts get loaded. The
    197    # `<span>` tags can be omitted by setting `font_unused_in_dom: True`,
    198    # allowing the test to validate what happens if the fonts aren't used in the
    199    # page.
    200    'font_unused_in_dom': False,
    201    # Python code generating an expected image using the pycairo library. This
    202    # expected image is included in the HTML test result page for
    203    # HTMLCanvasElement JavaScript tests, only for informational purposes and
    204    # for allowing manual visual verifications. It is NOT used by the test
    205    # runner to automatically check for test success. To automate test
    206    # validation, use a reftest instead, using the `reference`,
    207    # `html_reference`, `cairo_reference` or `img_reference` config.
    208    #
    209    # The Python script must start with the (non-Pythonic) magic line: size x y
    210    # Where x and y are the size of the image to generate. The remaining is
    211    # standard Python code, where the variables `surface` and `cr` are
    212    # respectively providing the `cairo.ImageSurface` and `cairo.Context`
    213    # objects to use for drawing the image.
    214    #
    215    # 'expected' accepts two special values: 'green' and 'clear'. These
    216    # respectively resolve to the images '/images/green-100x50.png' and
    217    # '/images/clear-100x50.png'. The test definitions can alternatively pass an
    218    # image filename explicitly by using `expected_img`.
    219    'expected': None,
    220    # When using the 'expected' option above, the name of the file that gets
    221    # generated is stored in 'expected_img', for the template to use. Test
    222    # definitions can alternatively specify a value for 'expected_img' directly,
    223    # without using 'expected', by passing it a filename to an image resource,
    224    # e.g. '/images/green-100x50.png'.
    225    'expected_img': None,
    226 
    227    # Test variants:
    228 
    229    # List of dictionaries, defining the dimensions of a test variant grid. Each
    230    # dictionary defines a variant dimension, with the dictionary keys
    231    # corresponding to different variant names and the values corresponding to
    232    # the parameters this variant should use.
    233    #
    234    # If only a single dictionary is provided, a different test will be
    235    # generated for each entries of this dictionary. For instance, the following
    236    # config will generate 2 tests:
    237    #   - name: 2d.example
    238    #     code: ctx.fillStyle = '{{ color }}';
    239    #     variants:
    240    #     - red: {color: '#F00'}
    241    #       blue: {color: '#00F'}
    242    #
    243    #   Will generate:
    244    #   1) '2d.example.red', with the code: `ctx.fillStyle = '#F00'`
    245    #   2) '2d.example.blue', with the code: `ctx.fillStyle = '#00F'`
    246    #
    247    # If more than one dictionaries are provided, each dictionary corresponds to
    248    # a dimension in a multi-dimensional variant grid. For instance, the
    249    # following config will generate 4 tests (using `variant_names[0]` to avoid
    250    # duplicating the same string in the variant name and parameter):
    251    #   - name: 2d.grid
    252    #     code: ctx.{{ variant_names[0] }} = '{{ color }}';
    253    #     variants:
    254    #     - fillStyle:
    255    #       shadowColor:
    256    #     - red: {color: '#F00'}
    257    #       blue: {color: '#00F'}
    258    #
    259    #   Will generate:
    260    #   1) '2d.grid.fillStyle.red', code: `ctx.fillStyle = '#F00';`
    261    #   2) '2d.grid.fillStyle.blue', code: `ctx.fillStyle = '#00F';`
    262    #   3) '2d.grid.shadowColor.red', code: `ctx.shadowColor = '#F00';`
    263    #   4) '2d.grid.shadowColor.blue', code: `ctx.shadowColor = '#00F';`
    264    #
    265    # The parameters of a variant (e.g. the 'color' parameter in the example
    266    # above) get merged over the base test parameter. For instance, a variant
    267    # could have the property 'code:', which overrides the 'code:' property in
    268    # the base test definition, if defined there:
    269    #   - name: 2d.parameter-override
    270    #     code: // Base code implementation.
    271    #     variants:
    272    #     - variant1:
    273    #         code: // Overrides base code implementation.
    274    'variants': None,
    275    # By default, each variant is generated to a different file. By using
    276    # 'variants_layout:', variants can be generated as multiple tests in the
    277    # same test file. If specified, 'variants_layout:' must be a list the same
    278    # length as the 'variants:' list, that is, as long as there are variant
    279    # dimensions. Each item in the `variants_layout` list indicate how that
    280    # particular variant dimension should be expanded. Possible values are:
    281    #
    282    # - multi_files
    283    #   This the default behavior: the variants along this dimension get
    284    #   generated to different files.
    285    #
    286    # - single_file
    287    #   The variants in this dimension get rendered to the same file.
    288    #
    289    # If multiple dimensions are marked as 'single_file', these variants get
    290    # laid-out in a grid whose width defaults to the number of variants in the
    291    # first 'single_file' dimension (the grid width can be customized using
    292    # 'grid_width:'). For instance:
    293    #
    294    # - name: grid-example
    295    #   variants:
    296    #   - A1:
    297    #     A2:
    298    #   - B1:
    299    #     B2:
    300    #   - C1:
    301    #     C2:
    302    #   - D1:
    303    #     D2:
    304    #   - E1:
    305    #     E2:
    306    #   variants_layout:
    307    #     - single_file
    308    #     - multi_files
    309    #     - single_file
    310    #     - multi_files
    311    #     - single_file
    312    #
    313    # Because this test has 2 'multi_files' dimensions with two variants each, 4
    314    # files would be generated:
    315    #   - grid-example.B1.D1
    316    #   - grid-example.B1.D2
    317    #   - grid-example.B2.D1
    318    #   - grid-example.B2.D2
    319    #
    320    # Then, the 3 'single_file' dimensions would produce 2x2x2 = 8 tests in each
    321    # of these files. For JavaScript tests, each of these tests would be
    322    # generated in sequence, each with their own `test()`, `async_test()` or
    323    # `promise_test()` fixture. Reftests on the other hand would produce a 2x2x2
    324    # grid, as follows:
    325    #    A1.C1.E1     A2.C1.E1
    326    #    A1.C2.E1     A2.C2.E1
    327    #    A1.C1.E2     A2.C1.E2
    328    #    A1.C2.E2     A2.C2.E2
    329    'variants_layout': None,
    330    # The width of the grid generated by the 'single_file' variant_layout. If
    331    # not specified, the size of the first 'single_file' variant dimension
    332    # is used as grid width.
    333    'grid_width': None,
    334    # If `True`, the file variant dimension names (i.e. the `file_variant_names`
    335    # property below) get appended to the test name. Setting this to `False` is
    336    # useful if a custom name format is desired, for instance:
    337    #     name: my_test.{{ file_variant_name }}.tentative
    338    'append_variants_to_name': True,
    339 }
    340 
    341 # Parameters automatically populated by the test generator. Test definitions
    342 # cannot manually specify a value for these, but they can be used in parameter
    343 # values using Jinja templating.
    344 _GENERATED_PARAMS = {
    345    # Set to either 'HtmlCanvas', 'Offscreen' or 'Worker' when rendering
    346    # templates for the corresponding canvas type. Test definitions can use this
    347    # parameter in Jinja `if` conditions to generate different code for
    348    # different canvas types.
    349    'canvas_type': None,
    350    # List holding the file variant dimension names. These get appended to
    351    # 'name' to form the test file name.
    352    'file_variant_names': [],
    353    # List of this variant grid dimension names. This uniquely identifies a
    354    # single variant in a variant grid file.
    355    'grid_variant_names': [],
    356    # List of this variant dimension names, including both file and grid
    357    # dimensions.
    358    'variant_names': [],
    359    # Same as `file_variant_names`, but concatenated into a single string. This
    360    # is a useful to easily identify a variant file.
    361    'file_variant_name': '',
    362    # Same as `grid_variant_names`, but concatenated into a single string. This
    363    # is a useful to easily identify a variant in a grid.
    364    'grid_variant_name': '',
    365    # Same as `variant_names`, but concatenated into a single string. This is a
    366    # useful shorthand for tests having a single variant dimension.
    367    'variant_name': '',
    368    # For reftests, this is the reference file name that the test file links to.
    369    'reference_file_link': None,
    370    # Numerical ID uniquely identifying this variant in a variant grid. This can
    371    # be used in `id` HTML attributes to allow each variant in a variant grid
    372    # to have uniquely identifiable HTML tags. For instance, an `html_reference`
    373    # with SVG code could give each variant a uniquely identifiable `<filter>`
    374    # tags by doing:
    375    #     <filter id="my_filter{{ id }}">
    376    'id': 0,
    377    # The file name of the test file being generated.
    378    'file_name': None,
    379    # Set to one of the enum values in `_TemplateType`, identifying the template
    380    # being used to generate the test.
    381    'template_type': None,
    382 }
    383 
    384 
    385 def _double_quote_escape(string: str) -> str:
    386    return string.replace('\\', '\\\\').replace('"', '\\"')
    387 
    388 
    389 def _escape_js(string: str) -> str:
    390    string = _double_quote_escape(string)
    391    # Kind of an ugly hack, for nicer failure-message output.
    392    string = re.sub(r'\[(\w+)\]', r'[\\""+(\1)+"\\"]', string)
    393    return string
    394 
    395 
    396 def _expand_nonfinite(method: str, argstr: str, tail: str) -> str:
    397    """
    398    >>> print _expand_nonfinite('f', '<0 a>, <0 b>', ';')
    399    f(a, 0);
    400    f(0, b);
    401    f(a, b);
    402    >>> print _expand_nonfinite('f', '<0 a>, <0 b c>, <0 d>', ';')
    403    f(a, 0, 0);
    404    f(0, b, 0);
    405    f(0, c, 0);
    406    f(0, 0, d);
    407    f(a, b, 0);
    408    f(a, b, d);
    409    f(a, 0, d);
    410    f(0, b, d);
    411    """
    412    # argstr is "<valid-1 invalid1-1 invalid2-1 ...>, ..." (where usually
    413    # 'invalid' is Infinity/-Infinity/NaN).
    414    args = []
    415    for arg in argstr.split(', '):
    416        match = re.match('<(.*)>', arg)
    417        if match is None:
    418            raise InvalidTestDefinitionError(
    419                f'Expected arg to match format "<(.*)>", but was: {arg}')
    420        a = match.group(1)
    421        args.append(a.split(' '))
    422    calls = []
    423    # Start with the valid argument list.
    424    call = [args[j][0] for j in range(len(args))]
    425    # For each argument alone, try setting it to all its invalid values:
    426    for i, arg in enumerate(args):
    427        for a in arg[1:]:
    428            c2 = call[:]
    429            c2[i] = a
    430            calls.append(c2)
    431    # For all combinations of >= 2 arguments, try setting them to their
    432    # first invalid values. (Don't do all invalid values, because the
    433    # number of combinations explodes.)
    434    def f(c: List[str], start: int, depth: int) -> None:
    435        for i in range(start, len(args)):
    436            if len(args[i]) > 1:
    437                a = args[i][1]
    438                c2 = c[:]
    439                c2[i] = a
    440                if depth > 0:
    441                    calls.append(c2)
    442                f(c2, i + 1, depth + 1)
    443 
    444    f(call, 0, 0)
    445 
    446    str_calls = (', '.join(c) for c in calls)
    447    return '\n'.join(f'{method}({params}){tail}' for params in str_calls)
    448 
    449 
    450 def _get_test_sub_dir(name: str, name_to_sub_dir: Mapping[str, str]) -> str:
    451    for prefix in sorted(name_to_sub_dir.keys(), key=len, reverse=True):
    452        if name.startswith(prefix):
    453            return name_to_sub_dir[prefix]
    454    raise InvalidTestDefinitionError(
    455        f'Test "{name}" has no defined target directory mapping')
    456 
    457 
    458 def _remove_extra_newlines(text: str) -> str:
    459    """Remove newlines if a backslash is found at end of line."""
    460    # Lines ending with '\' gets their newline character removed.
    461    text = re.sub(r'\\\n', '', text, flags=re.MULTILINE | re.DOTALL)
    462 
    463    # Lines ending with '\-' gets their newline and any leading white spaces on
    464    # the following line removed.
    465    text = re.sub(r'\\-\n\s*', '', text, flags=re.MULTILINE | re.DOTALL)
    466    return text
    467 
    468 
    469 def _expand_test_code(code: str) -> str:
    470    code = _remove_extra_newlines(code)
    471 
    472    code = re.sub(r' @moz-todo', '', code)
    473 
    474    code = re.sub(r'@moz-UniversalBrowserRead;', '', code)
    475 
    476    code = re.sub(r'@nonfinite ([^(]+)\(([^)]+)\)(.*)', lambda m:
    477                  _expand_nonfinite(m.group(1), m.group(2), m.group(3)),
    478                  code)  # Must come before '@assert throws'.
    479 
    480    code = re.sub(r'@assert pixel (\d+,\d+) == (\d+,\d+,\d+,\d+);',
    481                  r'_assertPixel(canvas, \1, \2);', code)
    482 
    483    code = re.sub(r'@assert pixel (\d+,\d+) ==~ (\d+,\d+,\d+,\d+);',
    484                  r'_assertPixelApprox(canvas, \1, \2, 2);', code)
    485 
    486    code = re.sub(r'@assert pixel (\d+,\d+) ==~ (\d+,\d+,\d+,\d+) \+/- (\d+);',
    487                  r'_assertPixelApprox(canvas, \1, \2, \3);', code)
    488 
    489    code = re.sub(r'@assert throws (\S+_ERR) (.*?);$',
    490                  r'assert_throws_dom("\1", function() { \2; });', code,
    491                  flags=re.MULTILINE | re.DOTALL)
    492 
    493    code = re.sub(r'@assert throws (\S+Error) (.*?);$',
    494                  r'assert_throws_js(\1, function() { \2; });', code,
    495                  flags=re.MULTILINE | re.DOTALL)
    496 
    497    code = re.sub(
    498        r'@assert (.*) === (.*);', lambda m:
    499        (f'_assertSame({m.group(1)}, {m.group(2)}, '
    500         f'"{_escape_js(m.group(1))}", "{_escape_js(m.group(2))}");'), code)
    501 
    502    code = re.sub(
    503        r'@assert (.*) !== (.*);', lambda m:
    504        (f'_assertDifferent({m.group(1)}, {m.group(2)}, '
    505         f'"{_escape_js(m.group(1))}", "{_escape_js(m.group(2))}");'), code)
    506 
    507    code = re.sub(
    508        r'@assert (.*) =~ (.*);',
    509        lambda m: f'assert_regexp_match({m.group(1)}, {m.group(2)});', code)
    510 
    511    code = re.sub(
    512        r'@assert (.*);',
    513        lambda m: f'_assert({m.group(1)}, "{_escape_js(m.group(1))}");', code)
    514 
    515    assert '@' not in code
    516 
    517    return code
    518 
    519 
    520 class MutableDictLoader(jinja2.BaseLoader):
    521    """Loads Jinja templates from a `dict` that can be updated.
    522 
    523    This is essentially a version of `jinja2.DictLoader` whose content can be
    524    changed. `jinja2.DictLoader` accepts a `dict` at construction time and that
    525    `dict` cannot be changed. The templates served by `MutableDictLoader` on the
    526    other hand can be updated by calling `set_templates(new_templates)`. This is
    527    needed because we reuse the environment to render different tests and
    528    variants, each of which will have different templates.
    529    """
    530 
    531    def __init__(self) -> None:
    532        self._templates = dict()  # type: Mapping[str, Any]
    533 
    534    def set_templates(self, new_templates: Mapping[str, Any]) -> None:
    535        """Changes the dict from which templates are loaded."""
    536        self._templates = new_templates
    537 
    538    def get_source(
    539        self, environment: jinja2.Environment, template: str
    540    ) ->  Tuple[str, str, Callable[[], bool]]:
    541        """Loads a template from the current template dict."""
    542        del environment  # Unused.
    543        source = self._templates.get(template)
    544        if source is None:
    545            raise jinja2.TemplateNotFound(template)
    546        if not isinstance(source, str):
    547            raise InvalidTestDefinitionError(
    548                f'Param "{template}" must be an str to be usable as Jinja '
    549                'template.')
    550        return source, template, lambda: source == self._templates.get(template)
    551 
    552 
    553 class TemplateLoaderActivator:
    554    """Helper class used to set a given params dict in a MutableDictLoader.
    555 
    556    Jinja requires custom loaders to be registered in the environment and thus,
    557    we can't dynamically change them. We would need this to allow different test
    558    variants to have different templates. Using a `TemplateLoaderActivator`,
    559    the code can "activate" the templates for a given variant before rendering
    560    strings for that variant. For instance:
    561 
    562        loader = MutableDictLoader()
    563        jinja_env = jinja2.Environment(loader=[loader])
    564 
    565        templates1 = {'macros': '{% macro foo() %}foo{% endmacro %}'}
    566        activator1 = TemplateLoaderActivator(loader, templates1)
    567 
    568        templates2 = {'macros': '{% macro foo() %}bar{% endmacro %}'}
    569        activator2 = TemplateLoaderActivator(loader, templates2)
    570 
    571        main_template = '''
    572            {% import 'macros' as t %}
    573            {{ t.foo() }}
    574        '''
    575 
    576        # Render `main_template`, loading 'macros' from `templates1.
    577        activator1.activate()
    578        jinja_env.from_string(main_template).render(params1))
    579 
    580        # Render `main_template`, loading 'macros' from `templates2.
    581        activator2.activate()
    582        jinja_env.from_string(main_template).render(params2))
    583 
    584    """
    585 
    586    def __init__(self, loader: MutableDictLoader, params: _TestParams) -> None:
    587        self._loader = loader
    588        self._params = params
    589 
    590    def activate(self):
    591        self._loader.set_templates(self._params)
    592 
    593 
    594 class _LazyRenderedStr(collections.UserString):
    595    """A custom str type that renders it's content with Jinja when accessed.
    596 
    597    This is an str-like type, storing a Jinja template, but returning the
    598    rendered version of that template when the string is accessed. The rendered
    599    result is cached and returned on subsequent accesses.
    600 
    601    This allows template parameters to be themselves templates. Template
    602    parameters can then refer to each other and they'll be rendered in the right
    603    order, in reverse order of access.
    604 
    605    For instance:
    606 
    607        params = {}
    608        make_lazy = lambda value: _LazyRenderedStr(
    609            jinja_env, loader_activator, params, value)
    610 
    611        params.update({
    612            'expected_value': make_lazy('rgba({{ color | join(", ") }})'),
    613            'color': [0, 255, 0, make_lazy('{{ alpha }}')],
    614            'alpha': 0.5,
    615        })
    616 
    617        main_template = 'assert value == "{{ expected_value }}"'
    618        result = jinja_env.from_string(main_template).render(params)
    619 
    620    In this example, upon rendering `main_template`, Jinja will first read
    621    `expected_value`, which reads `color`, which reads `alpha`. These will be
    622    rendered in reverse order, with `color` resolving to `[0, 255, 0, '0.5']`,
    623    `expected_value` resolving to 'rgba(0, 255, 0, 0.5)' and the final render
    624    resolving to: 'assert value == "rgba(0, 255, 0, 0.5)"'
    625    """
    626 
    627    def __init__(self, jinja_env: jinja2.Environment,
    628                 loader_activator: TemplateLoaderActivator,
    629                 params: _TestParams, value: str):
    630        # Don't call `super().__init__`, because we want to override `self.data`
    631        # to be a property instead of a member variable.
    632        # pylint: disable=super-init-not-called
    633        self._jinja_env = jinja_env
    634        self._loader_activator = loader_activator
    635        self._params = params
    636        self._value = value
    637        self._rendered = None
    638 
    639    @property
    640    def data(self):
    641        """Property returning the content of the `UserString`.
    642 
    643        This `_LazyRenderedStr` will be rendered on the first access. The
    644        rendered result is cached and returned directly on subsequent
    645        accesses."""
    646        if self._rendered is None:
    647            self._loader_activator.activate()
    648            self._rendered = (
    649                self._jinja_env.from_string(self._value).render(self._params))
    650        return self._rendered
    651 
    652    @property
    653    def __class__(self):
    654        """Makes `UserString` return any newly created strings as `str` objects.
    655 
    656        `UserString` functions returning a new string (e.g. `strip()`,
    657        `lower()`, etc.) normally return a string of the same type as the input
    658        `UserString`. It does do by using `__class__` to know the actual user
    659        string type. In our case, the result of these operations will always
    660        return a plain `str`, since any templating will have been rendered when
    661        reading the input string via `self.data`."""
    662        return str
    663 
    664 
    665 def _make_lazy_rendered(jinja_env: jinja2.Environment,
    666                        loader_activator: TemplateLoaderActivator,
    667                        params: _TestParams,
    668                        value: Any) -> Any:
    669    """Recursively converts `value` to a _LazyRenderedStr.
    670 
    671    If `value` is a data structure, this function recurses into that structure
    672    and converts leaf objects. Any `str` found containing Jinja tags are
    673    converted to _LazyRenderedStr.
    674    """
    675    if isinstance(value, str) and ('{{' in value or '{%' in value):
    676        return _LazyRenderedStr(jinja_env, loader_activator, params, value)
    677    if isinstance(value, list):
    678        return [_make_lazy_rendered(jinja_env, loader_activator, params, v)
    679                for v in value]
    680    if isinstance(value, tuple):
    681        return tuple(_make_lazy_rendered(jinja_env, loader_activator, params, v)
    682                     for v in value)
    683    if isinstance(value, dict):
    684        return {k: _make_lazy_rendered(jinja_env, loader_activator, params, v)
    685                for k, v in value.items()}
    686    return value
    687 
    688 
    689 def _ensure_rendered(value: Any) -> Any:
    690    """Recursively makes sure that all _LazyRenderedStr in `value` are rendered.
    691 
    692    If `value` is a data structure, this function recurses into that structure
    693    and renders any _LazyRenderedStr found."""
    694    if isinstance(value, _LazyRenderedStr):
    695        return str(value)
    696    if isinstance(value, list):
    697        return [_ensure_rendered(v) for v in value]
    698    if isinstance(value, tuple):
    699        return tuple(_ensure_rendered(v) for v in value)
    700    if isinstance(value, dict):
    701        return {k: _ensure_rendered(v) for k, v in value.items()}
    702    return value
    703 
    704 
    705 @dataclasses.dataclass
    706 class _OutputPaths:
    707    element: pathlib.Path
    708    offscreen: pathlib.Path
    709 
    710    def sub_path(self, sub_dir: str):
    711        """Create a new _OutputPaths that is a subpath of this _OutputPath."""
    712        return _OutputPaths(
    713            element=self.element / _ensure_rendered(sub_dir),
    714            offscreen=self.offscreen / _ensure_rendered(sub_dir))
    715 
    716    def path_for_canvas_type(self, canvas_type: _CanvasType) -> pathlib.Path:
    717        return (self.element if canvas_type == _CanvasType.HTML_CANVAS
    718                else self.offscreen)
    719 
    720    def mkdir(self) -> None:
    721        """Creates element and offscreen directories, if they don't exist."""
    722        self.element.mkdir(parents=True, exist_ok=True)
    723        self.offscreen.mkdir(parents=True, exist_ok=True)
    724 
    725 
    726 def _check_reserved_params(test: _TestParams):
    727    for param in _GENERATED_PARAMS:
    728        if test.get(param) is not None:
    729            raise InvalidTestDefinitionError(
    730                f'Parameter "{param}:" is reserved and cannot be manually '
    731                'specified in test definitions.')
    732 
    733 
    734 def _validate_test(test: _TestParams):
    735    for param in ['name', 'code']:
    736        if test.get(param) is None:
    737            raise InvalidTestDefinitionError(
    738                f'Test parameter "{param}" must be specified.')
    739 
    740    if test.get('expected', '') == 'green' and re.search(
    741            r'@assert pixel .* 0,0,0,0;', test['code']):
    742        print(f'Probable incorrect pixel test in {test["name"]}')
    743 
    744    if 'size' in test and (not isinstance(test['size'], tuple)
    745                           or len(test['size']) != 2):
    746        raise InvalidTestDefinitionError(
    747            f'Invalid canvas size "{test["size"]}" in test {test["name"]}. '
    748            'Expected an array with two numbers.')
    749 
    750    if test['template_type'] == _TemplateType.TESTHARNESS:
    751        valid_test_types = {'sync', 'async', 'promise'}
    752    else:
    753        valid_test_types = {'promise'}
    754 
    755    test_type = test.get('test_type')
    756    if test_type is not None and test_type not in valid_test_types:
    757        raise InvalidTestDefinitionError(
    758            f'Invalid test_type: {test_type}. '
    759            f'Valid values are: {valid_test_types}.')
    760 
    761 
    762 def _render(jinja_env: jinja2.Environment,
    763            template_name: str,
    764            params: _TestParams, output_file_name: str):
    765    template = jinja_env.get_template(template_name)
    766    file_content = template.render(params)
    767    pathlib.Path(output_file_name).write_text(file_content, 'utf-8')
    768 
    769 
    770 def _write_cairo_images(pycairo_code: str, output_file: pathlib.Path) -> None:
    771    """Creates a png from pycairo code and write it to `output_file`."""
    772    full_code = (f'{pycairo_code}\n'
    773                 f'surface.write_to_png("{output_file}")\n')
    774    eval(compile(full_code, '<string>', 'exec'), {
    775        'cairo': cairo,
    776        'math': math,
    777    })
    778 
    779 
    780 class _Variant():
    781 
    782    def __init__(self, params: _MutableTestParams) -> None:
    783        # Raw parameters, as specified in YAML, defining this test variant.
    784        self._params = params  # type: _MutableTestParams
    785        # Parameters rendered for each enabled canvas types.
    786        self._canvas_type_params = {
    787            }  # type: MutableMapping[_CanvasType, _MutableTestParams]
    788 
    789    @property
    790    def params(self) -> _MutableTestParams:
    791        """Returns this variant's raw param dict, as it's defined in YAML."""
    792        return self._params
    793 
    794    @property
    795    def canvas_type_params(self) -> MutableMapping[_CanvasType,
    796                                                   _MutableTestParams]:
    797        """Returns this variant's param dict for different canvas types."""
    798        return self._canvas_type_params
    799 
    800    @staticmethod
    801    def create_with_defaults(test: _TestParams) -> '_Variant':
    802        """Create a _Variant from the specified params.
    803 
    804        Default values are added for certain parameters, if missing."""
    805        # Pick up all default values from the parameter definition constants,
    806        # but drop all `None` values as they are only there as placeholders, to
    807        # allow all parameters to be listed for documentation purposes.
    808        params = {k: v
    809                  for defaults in (_TEST_DEFINITION_PARAMS, _GENERATED_PARAMS)
    810                  for k, v in defaults.items()
    811                  if v is not None}
    812        params.update(test)
    813 
    814        if 'variants' in params:
    815            del params['variants']
    816        return _Variant(params)
    817 
    818    def merge_params(self, params: _TestParams) -> '_Variant':
    819        """Returns a new `_Variant` that merges `self.params` and `params`."""
    820        new_params = copy.deepcopy(self._params)
    821        new_params.update(params)
    822        return _Variant(new_params)
    823 
    824    def _add_variant_name(self, name: str) -> None:
    825        self._params['variant_name'] += (
    826            ('.' if self.params['variant_name'] else '') + name)
    827        self._params['variant_names'] += [name]
    828 
    829    def with_grid_variant_name(self, name: str) -> '_Variant':
    830        """Addend a variant name to include in the grid element label."""
    831        self._add_variant_name(name)
    832        self._params['grid_variant_name'] += (
    833            ('.' if self.params['grid_variant_name'] else '') + name)
    834        self._params['grid_variant_names'] += [name]
    835        return self
    836 
    837    def with_file_variant_name(self, name: str) -> '_Variant':
    838        """Addend a variant name to include in the generated file name."""
    839        self._add_variant_name(name)
    840        self._params['file_variant_name'] += (
    841            ('.' if self.params['file_variant_name'] else '') + name)
    842        self._params['file_variant_names'] += [name]
    843        if self.params.get('append_variants_to_name', True):
    844            self._params['name'] += '.' + name
    845        return self
    846 
    847    def _get_file_name(self) -> str:
    848        file_name = self.params['name']
    849 
    850        if 'manual' in self.params:
    851            file_name += '-manual'
    852 
    853        return file_name
    854 
    855    def _get_canvas_types(self) -> FrozenSet[_CanvasType]:
    856        canvas_types = self.params.get('canvas_types', _CanvasType)
    857        invalid_types = {
    858            type
    859            for type in canvas_types if type not in list(_CanvasType)
    860        }
    861        if invalid_types:
    862            raise InvalidTestDefinitionError(
    863                f'Invalid canvas_types: {list(invalid_types)}. '
    864                f'Accepted values are: {[t.value for t in _CanvasType]}')
    865        return frozenset(_CanvasType(t) for t in canvas_types)
    866 
    867    def _get_template_type(self) -> _TemplateType:
    868        reference_types = sum(t in self.params for t in _REFERENCE_TEMPLATES)
    869        if reference_types > 1:
    870            raise InvalidTestDefinitionError(
    871                f'Test {self.params["name"]} is invalid, only one of '
    872                f'{[t.value for t in _REFERENCE_TEMPLATES]} can be specified '
    873                'at the same time.')
    874 
    875        for template_type in _REFERENCE_TEMPLATES:
    876            if template_type.value in self.params:
    877                return template_type
    878        return _TemplateType.TESTHARNESS
    879 
    880    def finalize_params(self, jinja_env: jinja2.Environment,
    881                        variant_id: int,
    882                        params_template_loader: MutableDictLoader) -> None:
    883        """Finalize this variant by adding computed param fields."""
    884        self._params['id'] = variant_id
    885        self._params['file_name'] = self._get_file_name()
    886        self._params['canvas_types'] = self._get_canvas_types()
    887        self._params['template_type'] = self._get_template_type()
    888 
    889        if isinstance(self._params['size'], list):
    890            self._params['size'] = tuple(self._params['size'])
    891 
    892        loader_activator = TemplateLoaderActivator(params_template_loader,
    893                                                   self._params)
    894        for canvas_type in self.params['canvas_types']:
    895            params = {'canvas_type': canvas_type}
    896            params.update(
    897                {k: _make_lazy_rendered(jinja_env, loader_activator, params, v)
    898                 for k, v in self._params.items()})
    899            self._canvas_type_params[canvas_type] = params
    900 
    901            for name in ('code', 'reference', 'html_reference',
    902                         'cairo_reference'):
    903                param = params.get(name)
    904                if param is not None:
    905                    params[name] = _expand_test_code(_ensure_rendered(param))
    906 
    907        _validate_test(self._params)
    908 
    909    def generate_expected_image(self, output_dirs: _OutputPaths) -> None:
    910        """Creates an expected image using Cairo and save filename in params."""
    911        # Expected images are only needed for HTML canvas tests.
    912        params = self._canvas_type_params.get(_CanvasType.HTML_CANVAS)
    913        if not params:
    914            return
    915 
    916        expected = _ensure_rendered(params['expected'])
    917 
    918        if expected == 'green':
    919            params['expected_img'] = '/images/green-100x50.png'
    920            return
    921        if expected == 'clear':
    922            params['expected_img'] = '/images/clear-100x50.png'
    923            return
    924        expected = re.sub(
    925            r'^size (\d+) (\d+)',
    926            r'surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, \1, \2)'
    927            r'\ncr = cairo.Context(surface)', expected)
    928 
    929        img_filename = f'{params["name"]}.png'
    930        _write_cairo_images(expected, output_dirs.element / img_filename)
    931        params['expected_img'] = img_filename
    932 
    933 
    934 class _VariantGrid:
    935 
    936    def __init__(self, variants: List[_Variant], grid_width: int) -> None:
    937        self._variants = variants
    938        self._grid_width = grid_width
    939 
    940        # Parameters rendered for each enabled canvas types.
    941        self._canvas_type_params = {
    942            }  # type: Mapping[_CanvasType, _MutableTestParams]
    943        self._enabled = None
    944        self._file_name = None
    945        self._canvas_types = None
    946        self._template_type = None
    947 
    948    @property
    949    def variants(self) -> List[_Variant]:
    950        """Read only getter for the list of variant in this grid."""
    951        return self._variants
    952 
    953    @property
    954    def enabled(self) -> bool:
    955        """File name to which this grid will be written."""
    956        if self._enabled is None:
    957            enabled_str = self._unique_param(_CanvasType, 'enabled')
    958            self._enabled = (enabled_str.strip().lower() == 'true')
    959        return self._enabled
    960 
    961    @property
    962    def file_name(self) -> str:
    963        """File name to which this grid will be written."""
    964        if self._file_name is None:
    965            self._file_name = self._unique_param(_CanvasType, 'file_name')
    966        return self._file_name
    967 
    968    @property
    969    def canvas_types(self) -> FrozenSet[_CanvasType]:
    970        """Returns the set of all _CanvasType used by this grid's variants."""
    971        if self._canvas_types is None:
    972            self._canvas_types = self._param_set(_CanvasType, 'canvas_types')
    973        return self._canvas_types
    974 
    975    @property
    976    def template_type(self) -> _TemplateType:
    977        """Returns the type of Jinja template needed to render this grid."""
    978        if self._template_type is None:
    979            self._template_type = self._unique_param(_CanvasType,
    980                                                     'template_type')
    981        return self._template_type
    982 
    983    def finalize(self, jinja_env: jinja2.Environment,
    984                 params_template_loader: MutableDictLoader):
    985        """Finalize this grid's variants, adding computed params fields."""
    986        for variant_id, variant in enumerate(self.variants):
    987            variant.finalize_params(jinja_env, variant_id,
    988                                    params_template_loader)
    989 
    990        if len(self.variants) == 1:
    991            self._canvas_type_params = self.variants[0].canvas_type_params
    992        else:
    993            self._canvas_type_params = self._get_grid_params()
    994 
    995    def add_dimension(self, variants: Mapping[str,
    996                                              _TestParams]) -> '_VariantGrid':
    997        """Adds a variant dimension to this variant grid.
    998 
    999        If the grid currently has N variants, adding a dimension with M variants
   1000        results in a grid containing N*M variants. Of course, we can't display
   1001        more than 2 dimensions on a 2D screen, so adding dimensions beyond 2
   1002        repeats all previous dimensions down vertically, with the grid width
   1003        set to the number of variants of the first dimension (unless overridden
   1004        by setting `grid_width`). For instance, a 3D variant space with
   1005        dimensions 3 x 2 x 2 will result in this layout:
   1006          000  100  200
   1007          010  110  210
   1008 
   1009          001  101  201
   1010          011  111  211
   1011        """
   1012        new_variants = [
   1013            old_variant.merge_params(params or {}).with_grid_variant_name(name)
   1014            for name, params in variants.items()
   1015            for old_variant in self.variants
   1016        ]
   1017        # The first dimension dictates the grid-width, unless it was specified
   1018        # beforehand via the test params.
   1019        new_grid_width = (self._grid_width
   1020                          if self._grid_width > 1 else len(variants))
   1021        return _VariantGrid(variants=new_variants, grid_width=new_grid_width)
   1022 
   1023    def merge_params(self, name: str, params: _TestParams) -> '_VariantGrid':
   1024        """Merges the specified `params` into every variant of this grid."""
   1025        return _VariantGrid(variants=[
   1026            variant.merge_params(params).with_file_variant_name(name)
   1027            for variant in self.variants
   1028        ],
   1029                            grid_width=self._grid_width)
   1030 
   1031    def _variants_for_canvas_type(
   1032            self, canvas_type: _CanvasType) -> List[_TestParams]:
   1033        """Returns the variants of this grid enabled for `canvas_type`."""
   1034        return [
   1035            v.canvas_type_params[canvas_type]
   1036            for v in self.variants
   1037            if canvas_type in v.canvas_type_params
   1038        ]
   1039 
   1040    def _unique_param(
   1041            self, canvas_types: Container[_CanvasType], name: str) -> Any:
   1042        """Returns the value of the `name` param for this grid.
   1043 
   1044        All the variants for all canvas types in `canvas_types` of this grid
   1045        must agree on the same value for this parameter, or else an exception is
   1046        thrown."""
   1047        values = {_ensure_rendered(params.get(name))
   1048                  for variant in self.variants
   1049                  for type, params in variant.canvas_type_params.items()
   1050                  if type in canvas_types}
   1051        if len(values) != 1:
   1052            raise InvalidTestDefinitionError(
   1053                'All variants in a variant grid must use the same value '
   1054                f'for property "{name}". Got these values: {values}. '
   1055                'Consider specifying the property outside of grid '
   1056                'variants dimensions (in the base test definition or in a '
   1057                'file variant dimension)')
   1058        return values.pop()
   1059 
   1060    def _param_set(self, canvas_types: Container[_CanvasType], name: str):
   1061        """Returns the set of all values this grid has for the `name` param.
   1062 
   1063        The `name` parameter of each variant is expected to be a sequence. These
   1064        are all accumulated in a set and returned. The values are accumulated
   1065        across all canvas types in `canvas_types`."""
   1066        return frozenset(sum([list(_ensure_rendered(params.get(name, [])))
   1067                              for v in self.variants
   1068                              for type, params in v.canvas_type_params.items()
   1069                              if type in canvas_types],
   1070                             []))
   1071 
   1072    def _get_grid_params(self) -> Mapping[_CanvasType, _MutableTestParams]:
   1073        """Returns the params dict needed to render this grid with Jinja."""
   1074        grid_params = {}
   1075        for canvas_type in self.canvas_types:
   1076            params = grid_params[canvas_type] = {}
   1077            params.update({
   1078                'variants': self._variants_for_canvas_type(canvas_type),
   1079                'grid_width': self._grid_width,
   1080                'name': self._unique_param([canvas_type], 'name'),
   1081                'test_type': self._unique_param([canvas_type], 'test_type'),
   1082                'fuzzy': self._unique_param([canvas_type], 'fuzzy'),
   1083                'timeout': self._unique_param([canvas_type], 'timeout'),
   1084                'notes': self._unique_param([canvas_type], 'notes'),
   1085                'images': self._param_set([canvas_type], 'images'),
   1086                'svgimages': self._param_set([canvas_type], 'svgimages'),
   1087                'fonts': self._param_set([canvas_type], 'fonts'),
   1088            })
   1089            if self.template_type in _REFERENCE_TEMPLATES:
   1090                params['desc'] = self._unique_param([canvas_type], 'desc')
   1091        return grid_params
   1092 
   1093    def _write_reference_test(self, jinja_env: jinja2.Environment,
   1094                              output_files: _OutputPaths):
   1095        grid = '_grid' if len(self.variants) > 1 else ''
   1096 
   1097        # If variants don't all use the same offscreen and worker canvas types,
   1098        # the offscreen and worker grids won't be identical. The worker test
   1099        # therefore can't reuse the offscreen reference file.
   1100        offscreen_types = {_CanvasType.OFFSCREEN_CANVAS, _CanvasType.WORKER}
   1101        needs_worker_reference = len({
   1102            variant.params['canvas_types'] & offscreen_types
   1103            for variant in self.variants
   1104        }) != 1
   1105 
   1106        test_templates = {
   1107            _CanvasType.HTML_CANVAS: f'reftest_element{grid}.html',
   1108            _CanvasType.OFFSCREEN_CANVAS: f'reftest_offscreen{grid}.html',
   1109            _CanvasType.WORKER: f'reftest_worker{grid}.html',
   1110        }
   1111        ref_templates = {
   1112            _TemplateType.REFERENCE: f'reftest_element{grid}.html',
   1113            _TemplateType.HTML_REFERENCE: f'reftest{grid}.html',
   1114            _TemplateType.CAIRO_REFERENCE: f'reftest_img{grid}.html',
   1115            _TemplateType.IMG_REFERENCE: f'reftest_img{grid}.html',
   1116        }
   1117        test_output_paths = {
   1118            _CanvasType.HTML_CANVAS: f'{output_files.element}.html',
   1119            _CanvasType.OFFSCREEN_CANVAS: f'{output_files.offscreen}.html',
   1120            _CanvasType.WORKER: f'{output_files.offscreen}.w.html',
   1121        }
   1122        ref_output_paths = {
   1123            _CanvasType.HTML_CANVAS: f'{output_files.element}-expected.html',
   1124            _CanvasType.OFFSCREEN_CANVAS:
   1125                f'{output_files.offscreen}-expected.html',
   1126            _CanvasType.WORKER: (
   1127                f'{output_files.offscreen}.w-expected.html'
   1128                if needs_worker_reference
   1129                else f'{output_files.offscreen}-expected.html'),
   1130        }
   1131        for canvas_type, params in self._canvas_type_params.items():
   1132            # Generate reference file.
   1133            if canvas_type != _CanvasType.WORKER or needs_worker_reference:
   1134                _render(jinja_env, ref_templates[self.template_type], params,
   1135                        ref_output_paths[canvas_type])
   1136 
   1137            # Generate test file, with a link to the reference file.
   1138            params['reference_file_link'] = pathlib.Path(
   1139                ref_output_paths[canvas_type]).name
   1140            _render(jinja_env, test_templates[canvas_type], params,
   1141                    test_output_paths[canvas_type])
   1142 
   1143    def _write_testharness_test(self, jinja_env: jinja2.Environment,
   1144                                output_files: _OutputPaths):
   1145        grid = '_grid' if len(self.variants) > 1 else ''
   1146 
   1147        templates = {
   1148            _CanvasType.HTML_CANVAS: f'testharness_element{grid}.html',
   1149            _CanvasType.OFFSCREEN_CANVAS: f'testharness_offscreen{grid}.html',
   1150            _CanvasType.WORKER: f'testharness_worker{grid}.js',
   1151        }
   1152        test_output_files = {
   1153            _CanvasType.HTML_CANVAS: f'{output_files.element}.html',
   1154            _CanvasType.OFFSCREEN_CANVAS: f'{output_files.offscreen}.html',
   1155            _CanvasType.WORKER: f'{output_files.offscreen}.worker.js',
   1156        }
   1157 
   1158        # Create test cases for canvas, offscreencanvas and worker.
   1159        for canvas_type, params in self._canvas_type_params.items():
   1160            _render(jinja_env, templates[canvas_type], params,
   1161                    test_output_files[canvas_type])
   1162 
   1163    def _generate_cairo_reference_grid(self,
   1164                                       canvas_type: _CanvasType,
   1165                                       output_dirs: _OutputPaths) -> None:
   1166        """Generate this grid's expected image from Cairo code, if needed.
   1167 
   1168        In order to cut on the number of files generated, the expected image
   1169        of all the variants in this grid are packed into a single PNG. The
   1170        expected HTML then contains a grid of <img> tags, each showing a portion
   1171        of the PNG file."""
   1172        if not any(v.canvas_type_params[canvas_type].get('cairo_reference')
   1173                   for v in self.variants):
   1174            return
   1175 
   1176        width, height = self._unique_param([canvas_type], 'size')
   1177        cairo_code = ''
   1178 
   1179        # First generate a function producing a Cairo surface with the expected
   1180        # image for each variant in the grid. The function is needed to provide
   1181        # a scope isolating the variant code from each other.
   1182        for idx, variant in enumerate(self._variants):
   1183            cairo_ref = variant.canvas_type_params[canvas_type].get(
   1184                'cairo_reference')
   1185            if not cairo_ref:
   1186                raise InvalidTestDefinitionError(
   1187                    'When used, "cairo_reference" must be specified for all '
   1188                    'test variants.')
   1189            cairo_code += textwrap.dedent(f'''\
   1190                def draw_ref{idx}():
   1191                  surface = cairo.ImageSurface(
   1192                      cairo.FORMAT_ARGB32, {width}, {height})
   1193                  cr = cairo.Context(surface)
   1194                {{}}
   1195                  return surface
   1196                  ''').format(textwrap.indent(cairo_ref, '  '))
   1197 
   1198        # Write all variant images into the final surface.
   1199        surface_width = width * self._grid_width
   1200        surface_height = (height *
   1201                          math.ceil(len(self._variants) / self._grid_width))
   1202        cairo_code += textwrap.dedent(f'''\
   1203            surface = cairo.ImageSurface(
   1204                cairo.FORMAT_ARGB32, {surface_width}, {surface_height})
   1205            cr = cairo.Context(surface)
   1206            ''')
   1207        for idx, variant in enumerate(self._variants):
   1208            x_pos = int(idx % self._grid_width) * width
   1209            y_pos = int(idx / self._grid_width) * height
   1210            cairo_code += textwrap.dedent(f'''\
   1211                cr.set_source_surface(draw_ref{idx}(), {x_pos}, {y_pos})
   1212                cr.paint()
   1213                ''')
   1214 
   1215        img_filename = f'{self.file_name}.png'
   1216        output_dir = output_dirs.path_for_canvas_type(canvas_type)
   1217        _write_cairo_images(cairo_code, output_dir / img_filename)
   1218        for v in self._variants:
   1219            v.canvas_type_params[canvas_type]['img_reference'] = img_filename
   1220 
   1221    def _generate_cairo_images(self, output_dirs: _OutputPaths) -> None:
   1222        """Generates the pycairo images found in the YAML test definition."""
   1223        # 'expected:' is only used for HTML_CANVAS tests.
   1224        has_expected = any(v.canvas_type_params
   1225                           .get(_CanvasType.HTML_CANVAS, {})
   1226                           .get('expected') for v in self._variants)
   1227        has_cairo_reference = any(
   1228            params.get('cairo_reference')
   1229            for v in self._variants
   1230            for params in v.canvas_type_params.values())
   1231 
   1232        if has_expected and has_cairo_reference:
   1233            raise InvalidTestDefinitionError(
   1234                'Parameters "expected" and "cairo_reference" can\'t be both '
   1235                'used at the same time.')
   1236 
   1237        if has_expected:
   1238            if len(self.variants) != 1:
   1239                raise InvalidTestDefinitionError(
   1240                    'Parameter "expected" is not supported for variant grids.')
   1241            if self.template_type != _TemplateType.TESTHARNESS:
   1242                raise InvalidTestDefinitionError(
   1243                    'Parameter "expected" is not supported in reference '
   1244                    'tests.')
   1245            self.variants[0].generate_expected_image(output_dirs)
   1246        elif has_cairo_reference:
   1247            for canvas_type in _CanvasType:
   1248                self._generate_cairo_reference_grid(canvas_type, output_dirs)
   1249 
   1250    def generate_test(self, jinja_env: jinja2.Environment,
   1251                      output_dirs: _OutputPaths) -> None:
   1252        """Generate the test files to the specified output dirs."""
   1253        self._generate_cairo_images(output_dirs)
   1254 
   1255        output_files = output_dirs.sub_path(self.file_name)
   1256 
   1257        if self.template_type in _REFERENCE_TEMPLATES:
   1258            self._write_reference_test(jinja_env, output_files)
   1259        else:
   1260            self._write_testharness_test(jinja_env, output_files)
   1261 
   1262 
   1263 class _VariantLayout(str, enum.Enum):
   1264    SINGLE_FILE = 'single_file'
   1265    MULTI_FILES = 'multi_files'
   1266 
   1267 
   1268 @dataclasses.dataclass
   1269 class _VariantDimension:
   1270    variants: Mapping[str, _TestParams]
   1271    layout: _VariantLayout
   1272 
   1273 
   1274 def _get_variant_dimensions(params: _TestParams) -> List[_VariantDimension]:
   1275    variants = params.get('variants', [])
   1276    if not isinstance(variants, list):
   1277        raise InvalidTestDefinitionError(
   1278            textwrap.dedent("""
   1279            Variants must be specified as a list of variant dimensions, e.g.:
   1280                variants:
   1281                - dimension1-variant1:
   1282                    param: ...
   1283                  dimension1-variant2:
   1284                    param: ...
   1285                - dimension2-variant1:
   1286                    param: ...
   1287                  dimension2-variant2:
   1288                    param: ..."""))
   1289 
   1290    variants_layout = params.get('variants_layout',
   1291                                 [_VariantLayout.MULTI_FILES] * len(variants))
   1292    if len(variants) != len(variants_layout):
   1293        raise InvalidTestDefinitionError(
   1294            'variants and variants_layout must be lists of the same size')
   1295    invalid_layouts = [
   1296        l for l in variants_layout if l not in list(_VariantLayout)
   1297    ]
   1298    if invalid_layouts:
   1299        raise InvalidTestDefinitionError('Invalid variants_layout: ' +
   1300                                         ', '.join(invalid_layouts) +
   1301                                         '. Valid layouts are: ' +
   1302                                         ', '.join(_VariantLayout))
   1303 
   1304    return [
   1305        _VariantDimension(z[0], z[1]) for z in zip(variants, variants_layout)
   1306    ]
   1307 
   1308 
   1309 def _get_variant_grids(
   1310    test: _TestParams,
   1311    jinja_env: jinja2.Environment,
   1312    params_template_loader: MutableDictLoader
   1313 ) -> List[_VariantGrid]:
   1314    base_variant = _Variant.create_with_defaults(test)
   1315    grid_width = base_variant.params.get('grid_width', 1)
   1316    if not isinstance(grid_width, int):
   1317        raise InvalidTestDefinitionError('"grid_width" must be an integer.')
   1318 
   1319    grids = [_VariantGrid([base_variant], grid_width=grid_width)]
   1320    for dimension in _get_variant_dimensions(test):
   1321        variants = dimension.variants
   1322        if dimension.layout == _VariantLayout.MULTI_FILES:
   1323            grids = [
   1324                grid.merge_params(name, params or {})
   1325                for name, params in variants.items() for grid in grids
   1326            ]
   1327        else:
   1328            grids = [grid.add_dimension(variants) for grid in grids]
   1329 
   1330    for grid in grids:
   1331        grid.finalize(jinja_env, params_template_loader)
   1332 
   1333    return grids
   1334 
   1335 
   1336 def _check_uniqueness(tested: DefaultDict[str, Set[_CanvasType]], name: str,
   1337                      canvas_types: FrozenSet[_CanvasType]) -> None:
   1338    already_tested = tested[name].intersection(canvas_types)
   1339    if already_tested:
   1340        raise InvalidTestDefinitionError(
   1341            f'Test {name} is defined twice for types {already_tested}')
   1342    tested[name].update(canvas_types)
   1343 
   1344 
   1345 def _indent_filter(s: str, width: Union[int, str] = 4,
   1346                   first: bool = False, blank: bool = False) -> str:
   1347    """Returns a copy of the string with each line indented by the `width` str.
   1348 
   1349    If `width` is a number, `s` is indented by that number of whitespaces. The
   1350    first line and blank lines are not indented by default, unless `first` or
   1351    `blank` are `True`, respectively.
   1352 
   1353    This is a re-implementation of the default `indent` Jinja filter, preserving
   1354    line ending characters (\r, \n, \f, etc.) The default `indent` Jinja filter
   1355    incorrectly replaces all of these characters with newlines."""
   1356    is_first_line = True
   1357    def indent_needed(line):
   1358        nonlocal first, blank, is_first_line
   1359        is_blank = not line.strip()
   1360        need_indent = (not is_first_line or first) and (not is_blank or blank)
   1361        is_first_line = False
   1362        return need_indent
   1363 
   1364    indentation = width if isinstance(width, str) else ' ' * width
   1365    return textwrap.indent(s, indentation, indent_needed)
   1366 
   1367 
   1368 def generate_test_files(name_to_dir_file: str) -> None:
   1369    """Generate Canvas tests from YAML file definition."""
   1370    output_dirs = _OutputPaths(element=pathlib.Path('..') / 'element',
   1371                               offscreen=pathlib.Path('..') / 'offscreen')
   1372 
   1373    params_template_loader = MutableDictLoader()
   1374 
   1375    jinja_env = jinja2.Environment(
   1376        loader=jinja2.ChoiceLoader([
   1377            jinja2.PackageLoader('gentest'),
   1378            params_template_loader,
   1379        ]),
   1380        keep_trailing_newline=True,
   1381        trim_blocks=True,
   1382        lstrip_blocks=True)
   1383 
   1384    jinja_env.filters['double_quote_escape'] = _double_quote_escape
   1385    jinja_env.filters['indent'] = _indent_filter
   1386 
   1387    # Run with --test argument to run unit tests.
   1388    if len(sys.argv) > 1 and sys.argv[1] == '--test':
   1389        doctest = importlib.import_module('doctest')
   1390        doctest.testmod()
   1391        sys.exit()
   1392 
   1393    name_to_sub_dir = (yaml.safe_load(
   1394        pathlib.Path(name_to_dir_file).read_text(encoding='utf-8')))
   1395 
   1396    tests = []
   1397    test_yaml_directory = 'yaml'
   1398    yaml_files = [
   1399        os.path.join(test_yaml_directory, f)
   1400        for f in os.listdir(test_yaml_directory) if f.endswith('.yaml')
   1401    ]
   1402    for t in sum([
   1403            yaml.safe_load(pathlib.Path(f).read_text(encoding='utf-8'))
   1404            for f in yaml_files
   1405    ], []):
   1406        if 'DISABLED' in t:
   1407            continue
   1408        if 'meta' in t:
   1409            eval(compile(t['meta'], '<meta test>', 'exec'), {},
   1410                 {'tests': tests})
   1411        else:
   1412            tests.append(t)
   1413 
   1414    for sub_dir in set(name_to_sub_dir.values()):
   1415        output_dirs.sub_path(sub_dir).mkdir()
   1416 
   1417    used_filenames = collections.defaultdict(set)
   1418    used_variants = collections.defaultdict(set)
   1419    for test in tests:
   1420        print(test['name'])
   1421        _check_reserved_params(test)
   1422        for grid in _get_variant_grids(test, jinja_env, params_template_loader):
   1423            if not grid.enabled:
   1424                continue
   1425            if test['name'] != grid.file_name:
   1426                print(f'  {grid.file_name}')
   1427 
   1428            _check_uniqueness(used_filenames, grid.file_name,
   1429                              grid.canvas_types)
   1430            for variant in grid.variants:
   1431                _check_uniqueness(
   1432                    used_variants,
   1433                    '.'.join([_ensure_rendered(grid.file_name)] +
   1434                             variant.params['grid_variant_names']),
   1435                    grid.canvas_types)
   1436 
   1437            sub_dir = _get_test_sub_dir(grid.file_name, name_to_sub_dir)
   1438            grid.generate_test(jinja_env, output_dirs.sub_path(sub_dir))
   1439 
   1440    print()
   1441 
   1442 
   1443 if __name__ == '__main__':
   1444    generate_test_files('name2dir.yaml')