diff --git a/.gitignore b/.gitignore index 1830b6e0e14..592cb8a8f0f 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ sync-local.sh .vim/ Userland/Libraries/LibWasm/Tests/Fixtures/SpecTests +Userland/Libraries/LibWasm/Tests/Spec diff --git a/CMakeLists.txt b/CMakeLists.txt index d5d3fc4e893..df038c2070b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -267,11 +267,9 @@ if(INCLUDE_WASM_SPEC_TESTS) file(GLOB WASM_TESTS "${CMAKE_BINARY_DIR}/testsuite-master/*.wast") foreach(PATH ${WASM_TESTS}) get_filename_component(NAME ${PATH} NAME_WLE) - message(STATUS "Compiling WebAssembly test ${NAME}...") + message(STATUS "Generating test cases for WebAssembly test ${NAME}...") execute_process( - COMMAND wasm-as -n ${PATH} -o "${WASM_SPEC_TEST_PATH}/${NAME}.wasm" - OUTPUT_QUIET - ERROR_QUIET) + COMMAND bash ${CMAKE_SOURCE_DIR}/Meta/generate-libwasm-spec-test.sh "${PATH}" "${CMAKE_SOURCE_DIR}/Userland/Libraries/LibWasm/Tests/Spec" "${NAME}" "${WASM_SPEC_TEST_PATH}") endforeach() file(REMOVE testsuite-master) endif() diff --git a/Meta/generate-libwasm-spec-test.py b/Meta/generate-libwasm-spec-test.py new file mode 100644 index 00000000000..dad5157131b --- /dev/null +++ b/Meta/generate-libwasm-spec-test.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 + +from sys import argv, stderr +from os import path +from string import whitespace +import re +import math +from tempfile import NamedTemporaryFile +from subprocess import call +import json + +atom_end = set('()"' + whitespace) + + +def parse(sexp): + sexp = re.sub(r'(?m)\(;.*;\)', '', re.sub(r'(;;.*)', '', sexp)) + stack, i, length = [[]], 0, len(sexp) + while i < length: + c = sexp[i] + kind = type(stack[-1]) + if kind == list: + if c == '(': + stack.append([]) + elif c == ')': + stack[-2].append(stack.pop()) + elif c == '"': + stack.append('') + elif c in whitespace: + pass + else: + stack.append((c,)) + elif kind == str: + if c == '"': + stack[-2].append(stack.pop()) + elif c == '\\': + i += 1 + stack[-1] += sexp[i] + else: + stack[-1] += c + elif kind == tuple: + if c in atom_end: + atom = stack.pop() + stack[-1].append(atom) + continue + else: + stack[-1] = ((stack[-1][0] + c),) + i += 1 + return stack.pop() + + +def parse_typed_value(ast): + types = { + 'i32.const': 'i32', + 'i64.const': 'i64', + 'f32.const': 'float', + 'f64.const': 'double', + } + if len(ast) == 2 and ast[0][0] in types: + return {"type": types[ast[0][0]], "value": ast[1][0]} + + return {"type": "error"} + + +def generate_module_source_for_compilation(entries): + s = '(' + for entry in entries: + if type(entry) == tuple and len(entry) == 1 and type(entry[0]) == str: + s += entry[0] + ' ' + elif type(entry) == str: + s += json.dumps(entry) + ' ' + elif type(entry) == list: + s += generate_module_source_for_compilation(entry) + else: + raise Exception("wat? I dunno how to pretty print " + str(type(entry))) + while s.endswith(' '): + s = s[:len(s) - 1] + return s + ')' + + +def generate(ast): + if type(ast) != list: + return [] + tests = [] + for entry in ast: + if len(entry) > 0 and entry[0] == ('module',): + tests.append({ + "module": generate_module_source_for_compilation(entry), + "tests": [] + }) + elif len(entry) in [2, 3] and entry[0][0].startswith('assert_'): + if entry[1][0] == ('invoke',): + tests[-1]["tests"].append({ + "kind": entry[0][0][len('assert_'):], + "function": { + "name": entry[1][1], + "args": list(parse_typed_value(x) for x in entry[1][2:]) + }, + "result": parse_typed_value(entry[2]) if len(entry) == 3 else None + }) + else: + print("Ignoring unknown assertion argument", entry[1][0], file=stderr) + elif len(entry) >= 2 and entry[0][0] == 'invoke': + # toplevel invoke :shrug: + tests[-1]["tests"].append({ + "kind": "ignore", + "function": { + "name": entry[1][1], + "args": list(parse_typed_value(x) for x in entry[1][2:]) + }, + "result": parse_typed_value(entry[2]) if len(entry) == 3 else None + }) + else: + print("Ignoring unknown entry", entry, file=stderr) + return tests + + +def genarg(spec): + if spec['type'] == 'error': + return '0' + + def gen(): + x = spec['value'] + if x == 'nan': + return 'NaN' + if x == '-nan': + return '-NaN' + + try: + x = float.fromhex(x) + if math.isnan(x): + # FIXME: This is going to mess up the different kinds of nan + return '-NaN' if math.copysign(1.0, x) < 0 else 'NaN' + if math.isinf(x): + return 'Infinity' if x > 0 else '-Infinity' + return str(x) + except ValueError: + try: + x = int(x, 0) + return str(x) + except ValueError: + return x + + x = gen() + if x.startswith('nan'): + return 'NaN' + if x.startswith('-nan'): + return '-NaN' + return x + + +all_names_in_main = {} + + +def genresult(ident, entry): + if entry['kind'] == 'return': + return_check = f'expect({ident}_result).toBe({genarg(entry["result"])})' if entry["result"] is not None else '' + return ( + f'let {ident}_result =' + f' module.invoke({ident}, {", ".join(genarg(x) for x in entry["function"]["args"])});\n ' + f'{return_check};\n ' + ) + + if entry['kind'] == 'trap': + return ( + f'expect(() => module.invoke({ident}, {", ".join(genarg(x) for x in entry["function"]["args"])}))' + '.toThrow(TypeError, "Execution trapped");\n ' + ) + + if entry['kind'] == 'ignore': + return f'module.invoke({ident}, {", ".join(genarg(x) for x in entry["function"]["args"])});\n ' + + return f'throw Exception("(Test Generator) Unknown test kind {entry["kind"]}");\n ' + + +def gentest(entry, main_name): + name = entry["function"]["name"] + if type(name) != str: + print("Unsupported test case (call to", name, ")", file=stderr) + return '\n ' + ident = '_' + re.sub("[^a-zA-Z_0-9]", "_", name) + count = all_names_in_main.get(name, 0) + all_names_in_main[name] = count + 1 + test_name = f'execution of {main_name}: {name} (instance {count})' + source = ( + f'test({json.dumps(test_name)}, () => {{\n' + f'let {ident} = module.getExport({json.dumps(name)});\n ' + f'expect({ident}).not.toBeUndefined();\n ' + f'{genresult(ident, entry)}' + '});\n\n ' + ) + return source + + +def gen_parse_module(name): + return ( + f'let content;\n ' + f'try {{\n ' + f'content = readBinaryWasmFile("Fixtures/SpecTests/{name}.wasm");\n ' + f'}} catch {{ read_okay = false; }}\n ' + f'const module = parseWebAssemblyModule(content)\n ' + ) + + +def main(): + with open(argv[1]) as f: + sexp = f.read() + name = argv[2] + module_output_path = argv[3] + ast = parse(sexp) + for index, description in enumerate(generate(ast)): + testname = f'{name}_{index}' + outpath = path.join(module_output_path, f'{testname}.wasm') + with NamedTemporaryFile("w+") as temp: + temp.write(description["module"]) + temp.flush() + rc = call(["wasm-as", "-n", temp.name, "-o", outpath]) + if rc != 0: + print("Failed to compile", name, "module index", index, "skipping that test", file=stderr) + continue + + sep = "" + print(f'''{{ +let readOkay = true; +{gen_parse_module(testname)} +if (readOkay) {{ +{sep.join(gentest(x, testname) for x in description["tests"])} +}}}} +''') + + +if __name__ == "__main__": + main() diff --git a/Meta/generate-libwasm-spec-test.sh b/Meta/generate-libwasm-spec-test.sh new file mode 100644 index 00000000000..37143baec6c --- /dev/null +++ b/Meta/generate-libwasm-spec-test.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +if [ $# -ne 4 ]; then + echo "Usage: $0 " + exit 1 +fi + +INPUT_FILE="$1" +OUTPUT_PATH="$2" +NAME="$3" +MODULE_OUTPUT_PATH="$4" + +mkdir -p "$OUTPUT_PATH" +mkdir -p "$MODULE_OUTPUT_PATH" + +python3 "$(dirname "$0")/generate-libwasm-spec-test.py" "$INPUT_FILE" "$NAME" "$MODULE_OUTPUT_PATH" | prettier --stdin-filepath "test-$NAME.js" > "$OUTPUT_PATH/$NAME.js" diff --git a/Tests/LibWasm/test-wasm.cpp b/Tests/LibWasm/test-wasm.cpp index c8f48a3002e..ac2eea0b061 100644 --- a/Tests/LibWasm/test-wasm.cpp +++ b/Tests/LibWasm/test-wasm.cpp @@ -27,6 +27,36 @@ TESTJS_GLOBAL_FUNCTION(read_binary_wasm_file, readBinaryWasmFile) return array; } +class WebAssemblyModule final : public JS::Object { + JS_OBJECT(WebAssemblyModule, JS::Object); + +public: + // FIXME: This should only contain an instantiated module, not the entire abstract machine! + explicit WebAssemblyModule(JS::Object& prototype) + : JS::Object(prototype) + { + } + + static WebAssemblyModule* create(JS::GlobalObject& global_object, Wasm::Module module) + { + auto instance = global_object.heap().allocate(global_object, *global_object.object_prototype()); + instance->m_module = move(module); + if (auto result = instance->m_machine.instantiate(*instance->m_module, {}); result.is_error()) + global_object.vm().throw_exception(global_object, result.release_error().error); + return instance; + } + void initialize(JS::GlobalObject&) override; + + ~WebAssemblyModule() override = default; + +private: + JS_DECLARE_NATIVE_FUNCTION(get_export); + JS_DECLARE_NATIVE_FUNCTION(wasm_invoke); + + Wasm::AbstractMachine m_machine; + Optional m_module; +}; + TESTJS_GLOBAL_FUNCTION(parse_webassembly_module, parseWebAssemblyModule) { auto object = vm.argument(0).to_object(global_object); @@ -43,9 +73,12 @@ TESTJS_GLOBAL_FUNCTION(parse_webassembly_module, parseWebAssemblyModule) vm.throw_exception(global_object, Wasm::parse_error_to_string(result.error())); return {}; } - if (stream.handle_any_error()) - return JS::js_undefined(); - return JS::js_null(); + + if (stream.handle_any_error()) { + vm.throw_exception(global_object, "Bianry stream contained errors"); + return {}; + } + return WebAssemblyModule::create(global_object, result.release_value()); } TESTJS_GLOBAL_FUNCTION(compare_typed_arrays, compareTypedArrays) @@ -68,3 +101,112 @@ TESTJS_GLOBAL_FUNCTION(compare_typed_arrays, compareTypedArrays) auto& rhs_array = static_cast(*rhs); return JS::Value(lhs_array.viewed_array_buffer()->buffer() == rhs_array.viewed_array_buffer()->buffer()); } + +void WebAssemblyModule::initialize(JS::GlobalObject& global_object) +{ + Base::initialize(global_object); + define_native_function("getExport", get_export); + define_native_function("invoke", wasm_invoke); +} + +JS_DEFINE_NATIVE_FUNCTION(WebAssemblyModule::get_export) +{ + auto name = vm.argument(0).to_string(global_object); + if (vm.exception()) + return {}; + auto this_value = vm.this_value(global_object); + auto object = this_value.to_object(global_object); + if (vm.exception()) + return {}; + if (!object || !is(object)) { + vm.throw_exception(global_object, "Not a WebAssemblyModule"); + return {}; + } + auto instance = static_cast(object); + for (auto& entry : instance->m_machine.module_instance().exports()) { + if (entry.name() == name) { + auto& value = entry.value(); + if (auto ptr = value.get_pointer()) + return JS::Value(static_cast(ptr->value())); + vm.throw_exception(global_object, String::formatted("'{}' does not refer to a function", name)); + return {}; + } + } + vm.throw_exception(global_object, String::formatted("'{}' could not be found", name)); + return {}; +} + +JS_DEFINE_NATIVE_FUNCTION(WebAssemblyModule::wasm_invoke) +{ + auto address = static_cast(vm.argument(0).to_double(global_object)); + if (vm.exception()) + return {}; + auto this_value = vm.this_value(global_object); + auto object = this_value.to_object(global_object); + if (vm.exception()) + return {}; + if (!object || !is(object)) { + vm.throw_exception(global_object, "Not a WebAssemblyModule"); + return {}; + } + auto instance = static_cast(object); + Wasm::FunctionAddress function_address { address }; + auto function_instance = instance->m_machine.store().get(function_address); + if (!function_instance) { + vm.throw_exception(global_object, "Invalid function address"); + return {}; + } + + const Wasm::FunctionType* type { nullptr }; + function_instance->visit([&](auto& value) { type = &value.type(); }); + if (!type) { + vm.throw_exception(global_object, "Invalid function found at given address"); + return {}; + } + + Vector arguments; + if (type->parameters().size() + 1 > vm.argument_count()) { + vm.throw_exception(global_object, String::formatted("Expected {} arguments for call, but found {}", type->parameters().size() + 1, vm.argument_count())); + return {}; + } + size_t index = 1; + for (auto& param : type->parameters()) { + auto value = vm.argument(index++).to_double(global_object); + switch (param.kind()) { + case Wasm::ValueType::Kind::I32: + arguments.append(Wasm::Value(static_cast(value))); + break; + case Wasm::ValueType::Kind::I64: + arguments.append(Wasm::Value(static_cast(value))); + break; + case Wasm::ValueType::Kind::F32: + arguments.append(Wasm::Value(static_cast(value))); + break; + case Wasm::ValueType::Kind::F64: + arguments.append(Wasm::Value(static_cast(value))); + break; + case Wasm::ValueType::Kind::FunctionReference: + arguments.append(Wasm::Value(Wasm::FunctionAddress { static_cast(value) })); + break; + case Wasm::ValueType::Kind::ExternReference: + arguments.append(Wasm::Value(Wasm::ExternAddress { static_cast(value) })); + break; + } + } + + auto result = instance->m_machine.invoke(function_address, arguments); + if (result.is_trap()) { + vm.throw_exception(global_object, "Execution trapped"); + return {}; + } + + if (result.values().is_empty()) + return JS::js_null(); + + JS::Value return_value; + result.values().first().value().visit( + [&](const auto& value) { return_value = JS::Value(static_cast(value)); }, + [&](const Wasm::FunctionAddress& index) { return_value = JS::Value(static_cast(index.value())); }, + [&](const Wasm::ExternAddress& index) { return_value = JS::Value(static_cast(index.value())); }); + return return_value; +}