diff options
Diffstat (limited to 'testing/web-platform/tests/html/canvas/tools/gentestutils.py')
-rw-r--r-- | testing/web-platform/tests/html/canvas/tools/gentestutils.py | 369 |
1 files changed, 369 insertions, 0 deletions
diff --git a/testing/web-platform/tests/html/canvas/tools/gentestutils.py b/testing/web-platform/tests/html/canvas/tools/gentestutils.py new file mode 100644 index 0000000000..8fa33e3975 --- /dev/null +++ b/testing/web-platform/tests/html/canvas/tools/gentestutils.py @@ -0,0 +1,369 @@ +# Current code status: +# +# This was originally written by Philip Taylor for use at +# http://philip.html5.org/tests/canvas/suite/tests/ +# +# It has been adapted for use with the Web Platform Test Suite suite at +# https://github.com/web-platform-tests/wpt/ +# +# The original version had a number of now-removed features (multiple versions +# of each test case of varying verbosity, Mozilla mochitests, semi-automated +# test harness). It also had a different directory structure. + +# To update or add test cases: +# +# * Modify the tests*.yaml files. +# - 'name' is an arbitrary hierarchical name to help categorise tests. +# - 'desc' is a rough description of what behaviour the test aims to test. +# - 'code' is JavaScript code to execute, with some special commands starting +# with '@' +# - 'expected' is what the final canvas output should be: a string 'green' or +# 'clear' (100x50 images in both cases), or a string 'size 100 50' (or any +# other size) followed by Python code using Pycairo to generate the image. +# +# * Run "./build.sh". +# This requires a few Python modules which might not be ubiquitous. +# It will usually emit some warnings, which ideally should be fixed but can +# generally be safely ignored. +# +# * Test the tests, add new ones to Git, remove deleted ones from Git, etc. + +from typing import List, Mapping + +import re +import importlib +import os +import pathlib +import sys +import textwrap + +try: + import cairocffi as cairo # type: ignore +except ImportError: + import cairo + +try: + # Compatible and lots faster. + import syck as yaml # type: ignore +except ImportError: + import yaml + + +class Error(Exception): + """Base class for all exceptions raised by this module""" + + +class InvalidTestDefinitionError(Error): + """Raised on invalid test definition.""" + + +def _simpleEscapeJS(string: str) -> str: + return string.replace('\\', '\\\\').replace('"', '\\"') + + +def _escapeJS(string: str) -> str: + string = _simpleEscapeJS(string) + # Kind of an ugly hack, for nicer failure-message output. + string = re.sub(r'\[(\w+)\]', r'[\\""+(\1)+"\\"]', string) + return string + + +def _expand_nonfinite(method: str, argstr: str, tail: str) -> str: + """ + >>> print _expand_nonfinite('f', '<0 a>, <0 b>', ';') + f(a, 0); + f(0, b); + f(a, b); + >>> print _expand_nonfinite('f', '<0 a>, <0 b c>, <0 d>', ';') + f(a, 0, 0); + f(0, b, 0); + f(0, c, 0); + f(0, 0, d); + f(a, b, 0); + f(a, b, d); + f(a, 0, d); + f(0, b, d); + """ + # argstr is "<valid-1 invalid1-1 invalid2-1 ...>, ..." (where usually + # 'invalid' is Infinity/-Infinity/NaN). + args = [] + for arg in argstr.split(', '): + match = re.match('<(.*)>', arg) + if match is None: + raise InvalidTestDefinitionError( + f"Expected arg to match format '<(.*)>', but was: {arg}") + a = match.group(1) + args.append(a.split(' ')) + calls = [] + # Start with the valid argument list. + call = [args[j][0] for j in range(len(args))] + # For each argument alone, try setting it to all its invalid values: + for i in range(len(args)): + for a in args[i][1:]: + c2 = call[:] + c2[i] = a + calls.append(c2) + # For all combinations of >= 2 arguments, try setting them to their + # first invalid values. (Don't do all invalid values, because the + # number of combinations explodes.) + def f(c: List[str], start: int, depth: int) -> None: + for i in range(start, len(args)): + if len(args[i]) > 1: + a = args[i][1] + c2 = c[:] + c2[i] = a + if depth > 0: + calls.append(c2) + f(c2, i + 1, depth + 1) + + f(call, 0, 0) + + return '\n'.join('%s(%s)%s' % (method, ', '.join(c), tail) for c in calls) + + +def _get_test_sub_dir(name: str, name_to_sub_dir: Mapping[str, str]) -> str: + for prefix in sorted(name_to_sub_dir.keys(), key=len, reverse=True): + if name.startswith(prefix): + return name_to_sub_dir[prefix] + raise InvalidTestDefinitionError( + 'Test "%s" has no defined target directory mapping' % name) + + +def _expand_test_code(code: str) -> str: + code = re.sub(r'@nonfinite ([^(]+)\(([^)]+)\)(.*)', lambda m: + _expand_nonfinite(m.group(1), m.group(2), m.group(3)), + code) # Must come before '@assert throws'. + + code = re.sub(r'@assert pixel (\d+,\d+) == (\d+,\d+,\d+,\d+);', + r'_assertPixel(canvas, \1, \2);', code) + + code = re.sub(r'@assert pixel (\d+,\d+) ==~ (\d+,\d+,\d+,\d+);', + r'_assertPixelApprox(canvas, \1, \2, 2);', code) + + code = re.sub(r'@assert pixel (\d+,\d+) ==~ (\d+,\d+,\d+,\d+) \+/- (\d+);', + r'_assertPixelApprox(canvas, \1, \2, \3);', code) + + code = re.sub(r'@assert throws (\S+_ERR) (.*);', + r'assert_throws_dom("\1", function() { \2; });', code) + + code = re.sub(r'@assert throws (\S+Error) (.*);', + r'assert_throws_js(\1, function() { \2; });', code) + + code = re.sub( + r'@assert (.*) === (.*);', lambda m: '_assertSame(%s, %s, "%s", "%s");' + % (m.group(1), m.group(2), _escapeJS(m.group(1)), _escapeJS(m.group(2)) + ), code) + + code = re.sub( + r'@assert (.*) !== (.*);', lambda m: + '_assertDifferent(%s, %s, "%s", "%s");' % (m.group(1), m.group( + 2), _escapeJS(m.group(1)), _escapeJS(m.group(2))), code) + + code = re.sub( + r'@assert (.*) =~ (.*);', lambda m: 'assert_regexp_match(%s, %s);' % ( + m.group(1), m.group(2)), code) + + code = re.sub( + r'@assert (.*);', lambda m: '_assert(%s, "%s");' % (m.group( + 1), _escapeJS(m.group(1))), code) + + code = re.sub(r' @moz-todo', '', code) + + code = re.sub(r'@moz-UniversalBrowserRead;', '', code) + + assert ('@' not in code) + + return code + + +_CANVAS_SIZE_REGEX = re.compile(r'(?P<width>.*), (?P<height>.*)', + re.MULTILINE | re.DOTALL) + + +def _get_canvas_size(test: Mapping[str, str]): + size = test.get('size', '100, 50') + match = _CANVAS_SIZE_REGEX.match(size) + if not match: + raise InvalidTestDefinitionError( + 'Invalid canvas size "%s" in test %s. Expected a string matching ' + 'this pattern: "%%s, %%s" %% (width, height)' % + (size, test['name'])) + return match.group('width'), match.group('height') + + +def _generate_test(test: Mapping[str, str], templates: Mapping[str, str], + sub_dir: str, test_output_dir: str, image_output_dir: str, + is_offscreen_canvas: bool): + name = test['name'] + + if test.get('expected', '') == 'green' and re.search( + r'@assert pixel .* 0,0,0,0;', test['code']): + print('Probable incorrect pixel test in %s' % name) + + code = _expand_test_code(test['code'].strip()) + code = textwrap.indent(code, ' ') + + expectation_html = '' + if 'expected' in test and test['expected'] is not None: + expected = test['expected'] + expected_img = None + if expected == 'green': + expected_img = '/images/green-100x50.png' + elif expected == 'clear': + expected_img = '/images/clear-100x50.png' + else: + if ';' in expected: + print('Found semicolon in %s' % name) + expected = re.sub( + r'^size (\d+) (\d+)', + r'surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, \1, \2)' + r'\ncr = cairo.Context(surface)', expected) + + expected += ("\nsurface.write_to_png('%s.png')\n" % + os.path.join(image_output_dir, sub_dir, name)) + eval(compile(expected, '<test %s>' % name, 'exec'), {}, + {'cairo': cairo}) + expected_img = '%s.png' % name + + if expected_img: + expectation_html = ( + '<p class="output expectedtext">Expected output:<p>' + '<img src="%s" class="output expected" id="expected" ' + 'alt="">' % expected_img) + + canvas = ' ' + test['canvas'] if 'canvas' in test else '' + width, height = _get_canvas_size(test) + + notes = '<p class="notes">%s' % test['notes'] if 'notes' in test else '' + + timeout = ('\n<meta name="timeout" content="%s">' % + test['timeout'] if 'timeout' in test else '') + timeout_js = ('// META: timeout=%s\n' % test['timeout'] + if 'timeout' in test else '') + + images = '' + for src in test.get('images', []): + img_id = src.split('/')[-1] + if '/' not in src: + src = '../images/%s' % src + images += '<img src="%s" id="%s" class="resource">\n' % (src, img_id) + for src in test.get('svgimages', []): + img_id = src.split('/')[-1] + if '/' not in src: + src = '../images/%s' % src + images += ('<svg><image xlink:href="%s" id="%s" class="resource">' + '</svg>\n' % (src, img_id)) + images = images.replace('../images/', '/images/') + + fonts = '' + fonthack = '' + for font in test.get('fonts', []): + fonts += ('@font-face {\n font-family: %s;\n' + ' src: url("/fonts/%s.ttf");\n}\n' % (font, font)) + # Browsers require the font to actually be used in the page. + if test.get('fonthack', 1): + fonthack += ('<span style="font-family: %s; position: ' + 'absolute; visibility: hidden">A</span>\n' % font) + if fonts: + fonts = '<style>\n%s</style>\n' % fonts + + fallback = test.get('fallback', + '<p class="fallback">FAIL (fallback content)</p>') + + desc = test.get('desc', '') + escaped_desc = _simpleEscapeJS(desc) + + attributes = test.get('attributes', '') + if attributes: + context_args = "'2d', %s" % attributes.strip() + attributes = ', ' + attributes.strip() + else: + context_args = "'2d'" + + template_params = { + 'name': name, + 'desc': desc, + 'escaped_desc': escaped_desc, + 'notes': notes, + 'images': images, + 'fonts': fonts, + 'fonthack': fonthack, + 'timeout': timeout, + 'timeout_js': timeout_js, + 'canvas': canvas, + 'width': width, + 'height': height, + 'expected': expectation_html, + 'code': code, + 'fallback': fallback, + 'attributes': attributes, + 'context_args': context_args + } + + test_path = os.path.join(test_output_dir, sub_dir, name) + if 'manual' in test: + test_path += '-manual' + + if is_offscreen_canvas: + pathlib.Path(f'{test_path}.html').write_text( + templates['offscreen'] % template_params, 'utf-8') + pathlib.Path(f'{test_path}.worker.js').write_text( + templates['worker'] % template_params, 'utf-8') + else: + pathlib.Path(f'{test_path}.html').write_text( + templates['element'] % template_params, 'utf-8') + + +def genTestUtils(TESTOUTPUTDIR: str, IMAGEOUTPUTDIR: str, TEMPLATEFILE: str, + NAME2DIRFILE: str, ISOFFSCREENCANVAS: bool) -> None: + # Run with --test argument to run unit tests + if len(sys.argv) > 1 and sys.argv[1] == '--test': + doctest = importlib.import_module('doctest') + doctest.testmod() + sys.exit() + + templates = yaml.safe_load(pathlib.Path(TEMPLATEFILE).read_text()) + name_to_sub_dir = yaml.safe_load(pathlib.Path(NAME2DIRFILE).read_text()) + + tests = [] + test_yaml_directory = 'yaml/element' + if ISOFFSCREENCANVAS: + test_yaml_directory = 'yaml/offscreen' + TESTSFILES = [ + os.path.join(test_yaml_directory, f) + for f in os.listdir(test_yaml_directory) if f.endswith('.yaml') + ] + for t in sum( + [yaml.safe_load(pathlib.Path(f).read_text()) for f in TESTSFILES], []): + if 'DISABLED' in t: + continue + if 'meta' in t: + eval(compile(t['meta'], '<meta test>', 'exec'), {}, + {'tests': tests}) + else: + tests.append(t) + + # Ensure the test output directories exist. + testdirs = [TESTOUTPUTDIR, IMAGEOUTPUTDIR] + for sub_dir in set(name_to_sub_dir.values()): + testdirs.append('%s/%s' % (TESTOUTPUTDIR, sub_dir)) + for d in testdirs: + try: + os.mkdir(d) + except FileExistsError: + pass # Ignore if it already exists. + + used_tests = {} + for test in tests: + name = test['name'] + print('\r(%s)' % name, ' ' * 32, '\t') + + if name in used_tests: + print('Test %s is defined twice' % name) + used_tests[name] = 1 + + sub_dir = _get_test_sub_dir(name, name_to_sub_dir) + _generate_test(test, templates, sub_dir, TESTOUTPUTDIR, IMAGEOUTPUTDIR, + ISOFFSCREENCANVAS) + + print() |