Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Native lifecycle tests

Run on macOS with a built Defold SDK (including the host's `script`, `lua`,
`dlib`, `profile_null`, and `ddf` libraries):

```sh
python3 tests/run_native_tests.py --defold-home /path/to/defold/tmp/dynamo_home
```

The tests compile the production callback code and use Defold's real Lua and
callback helpers. They exercise callback arguments, navigation return values,
deleted script instances, stale generations after view-slot reuse, destruction/replacement during callbacks with forced
garbage collection, error handling, stack restoration, and reference cleanup.
AddressSanitizer and UndefinedBehaviorSanitizer instrument the test and extension
code. Pass `--sanitizer thread` to use ThreadSanitizer instead of AddressSanitizer.
A second executable exercises the production Darwin queue and delegates,
including invalid IDs, slot reuse within one drained batch, cancellation of
pending navigation decisions, concurrent queue draining, a producer racing shutdown, and late events after
finalization/reinitialization. These tests use platform/script-boundary test
doubles and create no browser windows; they do not replace testing the native
WebView lifecycle on Android and Apple devices.

## Device regression project

Copy `tests/lifecycle` to a temporary directory, then copy this checkout's
`webview` directory into that project. Open its `game.project` in Defold and
bundle it for Android, iOS, or macOS. It has no remote dependencies.

The project destroys an unopened view, repeatedly destroys/recreates views in
the same slot while JavaScript emits events, alternates visible and hidden views,
and destroys a view from inside its callback. It must print
`WebView lifecycle regression passed` and exit without crashes, assertion errors,
callbacks after destruction, or events delivered to a replacement view.

Run Android debug and release bundles, including armeabi-v7a, for issue #40.
The host runner checks this script's Lua syntax but does not execute the device test.
1 change: 1 addition & 0 deletions tests/lifecycle/game.input_binding
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

21 changes: 21 additions & 0 deletions tests/lifecycle/game.project
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[project]
title = WebView lifecycle regression

[bootstrap]
main_collection = /main.collectionc

[input]
game_binding = /game.input_bindingc

[display]
width = 320
height = 480

[script]
shared_state = 1

[android]
package = com.defold.webview.lifecycle

[ios]
bundle_identifier = com.defold.webview.lifecycle
8 changes: 8 additions & 0 deletions tests/lifecycle/main.collection
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
name: "main"
embedded_instances {
id: "main"
data: "components {\n"
" id: \"script\"\n"
" component: \"/main.script\"\n"
"}\n"
}
62 changes: 62 additions & 0 deletions tests/lifecycle/main.script
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
-- Bundle this project with the local webview directory to exercise native teardown.
local function destroy(record)
if not record.dead then
record.dead = true
assert(webview.destroy(record.id) == 0)
end
end

local function create(self, close_in_callback)
self.serial = self.serial + 1
local record = { token = "webview-lifetime-" .. self.serial }
record.id = webview.create(function(_, id, request, event, data)
assert(not record.dead, "callback delivered after destruction")
if data.result then
local token = data.result:match("webview%-lifetime%-%d+")
assert(not token or token == record.token, "event delivered to a replacement view")
end
if close_in_callback then
record.id = id
destroy(record)
self.closed_in_callback = true
end
return true
end)
assert(record.id >= 0)
local html = "<html><body>WebView lifecycle test<script>" ..
"setInterval(function(){console.error('" .. record.token .. "')},10);" ..
"</script></body></html>"
webview.open_raw(record.id, html, { hidden = self.serial % 2 == 0, transparent = false })
if not record.dead then
webview.eval(record.id, "'" .. record.token .. "'")
end
return record
end

function init(self)
assert(webview, "run on Android, iOS, or macOS with the local extension")
-- A view that was never attached to a window must also be safe to destroy.
local unseen = webview.create(function() error("unexpected callback for unopened view") end)
assert(unseen >= 0 and webview.destroy(unseen) == 0)

self.serial = 0
self.current = create(self, false)
self.loop = timer.delay(0.1, true, function()
destroy(self.current)
if self.serial < 20 then
self.current = create(self, false)
else
timer.cancel(self.loop)
self.current = create(self, true)
timer.delay(5, false, function()
assert(self.closed_in_callback, "no callback received before timeout")
print("WebView lifecycle regression passed")
sys.exit(0)
end)
end
end)
end

function final(self)
if self.current then destroy(self.current) end
end
40 changes: 40 additions & 0 deletions tests/run_native_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Run host regression tests against a built Defold SDK on macOS."""
import argparse
import os
from pathlib import Path
import platform
import subprocess
import tempfile

parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--defold-home", default=os.environ.get("WEBVIEW_DEFOLD_HOME"),
help="Defold build/install directory containing sdk/include and lib/<platform>")
parser.add_argument("--sanitizer", choices=["address", "thread"], default="address")
args = parser.parse_args()
if not args.defold_home:
parser.error("provide --defold-home or WEBVIEW_DEFOLD_HOME")
if platform.system() != "Darwin":
parser.error("this runner currently supports macOS hosts")
sdk = Path(args.defold_home).resolve()
root = Path(__file__).resolve().parents[1]
host = "arm64-macos" if platform.machine() == "arm64" else "x86_64-macos"
with tempfile.TemporaryDirectory(prefix="webview-tests-") as directory:
common_flags = [
"clang++", "-std=c++11", "-g", "-fsanitize=" + args.sanitizer + ",undefined",
"-fno-omit-frame-pointer", "-Wl,-dead_strip", "-Wno-nontrivial-memcall",
"-DDM_PLATFORM_OSX", '-DDLIB_LOG_DOMAIN="WEBVIEW_TEST"',
"-I" + str(sdk / "sdk/include"), "-I" + str(sdk / "include"),
"-I" + str(root / "webview/src"),
"-L" + str(sdk / "lib" / host), "-lscript", "-llua", "-ldlib",
"-lprofile_null", "-lddf", "-framework", "CoreFoundation",
]
for name, sources, flags in [
("test_callbacks", ["tests/test_callbacks.cpp", "webview/src/webview_common.cpp"], []),
("test_darwin", ["tests/test_darwin.mm"], ["-framework", "WebKit", "-framework", "AppKit"]),
]:
executable = Path(directory) / name
subprocess.run(common_flags + [str(root / source) for source in sources] +
flags + ["-o", str(executable)], check=True)
arguments = [str(root / "tests/lifecycle/main.script")] if name == "test_callbacks" else []
subprocess.run([str(executable)] + arguments, check=True)
211 changes: 211 additions & 0 deletions tests/test_callbacks.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// Exercises the production callback code with Defold's real Lua and script libraries.
#include <assert.h>
#include <stdio.h>
#include <string>
#include "webview_common.h"
extern "C" {
#include <lua/lualib.h>
}

// Engine helpers used to create a script-instance fixture and check reference accounting.
namespace dmScript {
int GetLuaRefCount();
uint32_t RegisterUserType(lua_State* L, const char* name, const luaL_reg methods[], const luaL_reg meta[]);
}

static dmWebView::WebViewInfo g_Info;
static int g_Allowed;
static int g_Cancelled;
static int g_ContextRef;
static bool g_InstanceValid = true;

namespace dmWebView {
int Platform_ContinueOpen(lua_State*, int, int request, const char*) { ++g_Allowed; return request; }
int Platform_CancelOpen(lua_State*, int, int request, const char*) { ++g_Cancelled; return request; }
}

static int InstanceValid(lua_State* L) { lua_pushboolean(L, g_InstanceValid); return 1; }
static int ContextRef(lua_State* L) { lua_pushinteger(L, g_ContextRef); return 1; }
static int ScriptId(lua_State* L) { lua_pushinteger(L, 1); return 1; }

static void RunLua(lua_State* L, const char* source)
{
int result = luaL_dostring(L, source);
if (result) fprintf(stderr, "%s\n", lua_tostring(L, -1));
assert(result == 0);
}

static void SetCallback(lua_State* L, const char* body)
{
std::string source = "return function(self, view, request, event, data) ";
source += body;
source += " end";
RunLua(L, source.c_str());
dmWebView::CreateWebViewInfo(&g_Info, L, -1);
assert(g_Info.m_Callback);
lua_pop(L, 1);
}

static int Destroy(lua_State*) { dmWebView::ClearWebViewInfo(&g_Info); return 0; }
static int Replace(lua_State* L)
{
dmWebView::ClearWebViewInfo(&g_Info);
SetCallback(L, "replacement_calls = replacement_calls + 1");
return 0;
}

static void Dispatch(lua_State* L, dmWebView::CallbackResult type = dmWebView::CALLBACK_RESULT_URL_LOADING,
uint64_t generation = 0)
{
dmWebView::CallbackInfo event;
event.m_Info = &g_Info;
event.m_Generation = generation ? generation : g_Info.m_Generation;
event.m_Type = type;
event.m_WebViewID = 2;
event.m_RequestID = 7;
event.m_Url = "https://example.invalid/";
event.m_Result = "tick";

// A pre-existing stack entry must not be mistaken for the callback result.
int top = lua_gettop(L);
lua_pushboolean(L, 0);
dmScript::GetInstance(L);
int previous = luaL_ref(L, LUA_REGISTRYINDEX);
lua_pushnil(L);
dmScript::SetInstance(L);
dmWebView::RunCallback(&event);
assert(lua_gettop(L) == top + 1);
dmScript::GetInstance(L);
assert(lua_isnil(L, -1));
lua_pop(L, 2);
lua_rawgeti(L, LUA_REGISTRYINDEX, previous);
dmScript::SetInstance(L);
luaL_unref(L, LUA_REGISTRYINDEX, previous);
}

// Verifies the public callback arguments and nil/true/false navigation decisions.
static void TestNavigation(lua_State* L)
{
const char* returns[] = { "return nil", "return true", "return false" };
for (int i = 0; i < 3; ++i)
{
std::string body = "assert(self ~= nil and view == 2 and request == 7 and event == -3); "
"assert(data.url == 'https://example.invalid/' and data.result == 'tick'); ";
body += returns[i];
SetCallback(L, body.c_str());
g_Allowed = g_Cancelled = 0;
Dispatch(L);
assert(g_Allowed == (i < 2));
assert(g_Cancelled == (i == 2));
dmWebView::ClearWebViewInfo(&g_Info);
}
}

// Verifies that missing callbacks and deleted script instances never enter Lua.
static void TestInvalidCallbacks(lua_State* L)
{
Dispatch(L);
RunLua(L, "invalid_calls = 0");
SetCallback(L, "invalid_calls = invalid_calls + 1");
g_InstanceValid = false;
Dispatch(L, dmWebView::CALLBACK_RESULT_EVAL_OK);
g_InstanceValid = true;
dmWebView::ClearWebViewInfo(&g_Info);
Dispatch(L);
dmWebView::ClearWebViewInfo(&g_Info);
RunLua(L, "assert(invalid_calls == 0)");
}

// Verifies teardown remains safe when Lua destroys/replaces the view and forces GC.
static void TestReentrantDestruction(lua_State* L)
{
const char* actions[] = { "destroy_view()", "replace_view()" };
for (int i = 0; i < 2; ++i)
{
std::string body = actions[i];
body += "; collectgarbage('collect'); return true";
SetCallback(L, body.c_str());
g_Allowed = g_Cancelled = 0;
Dispatch(L);
assert(g_Allowed == 0 && g_Cancelled == 0);
if (i == 1)
{
Dispatch(L, dmWebView::CALLBACK_RESULT_EVAL_OK);
RunLua(L, "assert(replacement_calls == 1)");
}
dmWebView::ClearWebViewInfo(&g_Info);
}
}

// Verifies Lua errors leave a balanced stack and cancel pending navigation.
static void TestCallbackError(lua_State* L)
{
SetCallback(L, "error('expected callback test error')");
g_Allowed = g_Cancelled = 0;
Dispatch(L);
assert(g_Allowed == 0 && g_Cancelled == 1);
dmWebView::ClearWebViewInfo(&g_Info);
}

// Verifies old events cannot reach a new callback after the same slot is reused.
static void TestStaleGeneration(lua_State* L)
{
RunLua(L, "stale_calls = 0");
SetCallback(L, "stale_calls = stale_calls + 1");
uint64_t old_generation = g_Info.m_Generation;
dmWebView::ClearWebViewInfo(&g_Info);
Dispatch(L, dmWebView::CALLBACK_RESULT_EVAL_OK, old_generation);
SetCallback(L, "stale_calls = stale_calls + 1");
assert(old_generation != g_Info.m_Generation);
// Covers both events queued before destruction and late events from the old producer.
Dispatch(L, dmWebView::CALLBACK_RESULT_EVAL_OK, old_generation);
Dispatch(L, dmWebView::CALLBACK_RESULT_URL_LOADING, old_generation);
RunLua(L, "assert(stale_calls == 0)");
Dispatch(L, dmWebView::CALLBACK_RESULT_EVAL_OK);
RunLua(L, "assert(stale_calls == 1)");
dmWebView::ClearWebViewInfo(&g_Info);
}

int main(int argc, char** argv)
{
lua_State* L = luaL_newstate();
luaL_openlibs(L);
lua_pushlightuserdata(L, L);
lua_setglobal(L, "__script_main_thread");
const luaL_reg methods[] = { { 0, 0 } };
const luaL_reg meta[] = {
{ "__is_valid", InstanceValid },
{ "__get_instance_context_table_ref", ContextRef },
{ "__get_unique_script_id", ScriptId },
{ 0, 0 }
};
dmScript::RegisterUserType(L, "WebViewTestInstance", methods, meta);
lua_newtable(L);
g_ContextRef = luaL_ref(L, LUA_REGISTRYINDEX);
lua_newuserdata(L, 1);
luaL_getmetatable(L, "WebViewTestInstance");
lua_setmetatable(L, -2);
dmScript::SetInstance(L);
lua_register(L, "destroy_view", Destroy);
lua_register(L, "replace_view", Replace);
RunLua(L, "replacement_calls = 0");

int references = dmScript::GetLuaRefCount();
TestNavigation(L);
TestInvalidCallbacks(L);
TestReentrantDestruction(L);
TestCallbackError(L);
TestStaleGeneration(L);
assert(dmScript::GetLuaRefCount() == references);
assert(lua_gettop(L) == 0);
for (int i = 1; i < argc; ++i)
{
int result = luaL_loadfile(L, argv[i]);
if (result) fprintf(stderr, "%s\n", lua_tostring(L, -1));
assert(result == 0);
lua_pop(L, 1);
}
lua_close(L);
puts("Callback lifecycle tests passed (real Defold Lua/script libraries).");
return 0;
}
Loading
Loading