diff --git a/BUILD.bazel b/BUILD.bazel index cbc8ae60e..1cd7baca6 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -12,3 +12,9 @@ npm_link_package( src = "@valdi//src/valdi_modules/src/valdi/valdi_core:valdi_core_dts", visibility = ["//visibility:public"], ) + +npm_link_package( + name = "web_renderer_link", + src = "@valdi//src/valdi_modules/src/valdi/web_renderer:web_renderer_dts", + visibility = ["//visibility:public"], +) diff --git a/ai-skills/skills/valdi-polyglot-module/skill.md b/ai-skills/skills/valdi-polyglot-module/skill.md index af1083ddd..ca6539256 100644 --- a/ai-skills/skills/valdi-polyglot-module/skill.md +++ b/ai-skills/skills/valdi-polyglot-module/skill.md @@ -62,9 +62,10 @@ Web entry files export view factories that are auto-registered with the `WebView ```typescript interface AttributeHandler { changeAttribute(name: string, value: unknown): void; + destroy?(): void; } -type ViewFactory = (container: HTMLElement) => AttributeHandler; +type ViewFactory = (container: HTMLElement) => AttributeHandler | void; function createMyViewFactory(): ViewFactory { return (container: HTMLElement): AttributeHandler => { @@ -77,6 +78,9 @@ function createMyViewFactory(): ViewFactory { element.textContent = String(value); } }, + destroy(): void { + element.remove(); + }, }; }; } diff --git a/apps/helloworld/src/valdi/hello_world/web/CppModule.ts b/apps/helloworld/src/valdi/hello_world/web/CppModule.ts index 3a94d1778..f45421ef4 100644 --- a/apps/helloworld/src/valdi/hello_world/web/CppModule.ts +++ b/apps/helloworld/src/valdi/hello_world/web/CppModule.ts @@ -1,7 +1,3 @@ -// Web override for the C++ native module. No-op on the browser; the -// original C++ implementation logs the root component ID via native -// bindings that don't exist here. Kept as a stub so the app boots. - -export function onRootComponentCreated(_contextId: string): void { - // no-op on web +export function onRootComponentCreated(contextId: string): void { + console.log(`From web: Root component created with contextId '${contextId}'`); } diff --git a/apps/helloworld/src/valdi/hello_world/web/NativeModule.ts b/apps/helloworld/src/valdi/hello_world/web/NativeModule.ts index 664a188a0..3b899178f 100644 --- a/apps/helloworld/src/valdi/hello_world/web/NativeModule.ts +++ b/apps/helloworld/src/valdi/hello_world/web/NativeModule.ts @@ -1,5 +1 @@ -// Web override for the platform-specific NativeModule. iOS/Android -// return the app name suffixed with the platform; on web we just say -// "Web". - -export const APP_NAME: string = 'Valdi Hello World (Web)'; +export const APP_NAME = 'Valdi Web'; diff --git a/apps/helloworld/src/valdi/hello_world/web/tsconfig.json b/apps/helloworld/src/valdi/hello_world/web/tsconfig.json index dac319734..fcb232e3c 100644 --- a/apps/helloworld/src/valdi/hello_world/web/tsconfig.json +++ b/apps/helloworld/src/valdi/hello_world/web/tsconfig.json @@ -7,5 +7,6 @@ "composite": true, "allowJs": true, "declaration": true - } + }, + "exclude": ["debug/**", "release/**"] } diff --git a/apps/integration_test/AGENTS.md b/apps/integration_test/AGENTS.md new file mode 100644 index 000000000..67eca7ae7 --- /dev/null +++ b/apps/integration_test/AGENTS.md @@ -0,0 +1,145 @@ +# Integration Test App Guide + +This directory contains Valdi's cross-platform snapshot integration test harness. It renders a curated set of Valdi elements and attributes, captures screenshots and node output on each platform, and compares results across platforms or repos. + +## What Is Here + +- `src/valdi/integration_test_app`: the Valdi app under test. + - `IntegrationTestCases.tsx` defines the cases. + - `IntegrationTestApp.tsx` renders one case at a time. + - `IntegrationTestRunner.ts` captures snapshots, observations, progress, and result JSON. + - `web/IntegrationTestHost.ts` provides the web host implementation for screenshots, synthetic input, and file writes. +- `src/valdi/integration_test_cli`: the CLI for running captures, comparing outputs, exporting snapshots, and self-testing the comparison logic. +- `src/ios`, `src/android`, and `src/cpp`: native host/module support used by the app and CLI. +- Top-level targets: + - `//apps/integration_test:integration_test` + - `//apps/integration_test:integration_test_cli` + - generated platform app targets such as `integration_test_ios`, `integration_test_android`, `integration_test_macos` + - web package target `//apps/integration_test:integration_test_web_npm` + +## Common Commands + +Build the CLI first: + +```bash +bazel build //apps/integration_test:integration_test_cli +``` + +For full saved comparisons or repeated `compare` runs, build/run the CLI with optimizations enabled. The comparison path can be much slower in non-optimized builds, especially when image conversion or resizing goes through SnapDrawing/Skia: + +```bash +bazel run -c opt //apps/integration_test:integration_test_cli -- compare \ + --before /private/tmp/valdi-integration-ios.json \ + --after /private/tmp/valdi-integration-web.json \ + --output-dir /private/tmp/valdi-integration-compare +``` + +Print usage: + +```bash +bazel-bin/apps/integration_test/integration_test_cli help +``` + +Run a web capture: + +```bash +bazel-bin/apps/integration_test/integration_test_cli run \ + --platform web \ + --output /private/tmp/valdi-integration-web.json \ + --timeout-ms 240000 +``` + +Run an iOS simulator capture: + +```bash +bazel-bin/apps/integration_test/integration_test_cli run \ + --platform ios \ + --device-id booted \ + --output /private/tmp/valdi-integration-ios.json \ + --timeout-ms 240000 +``` + +Compare two result JSON files: + +```bash +bazel-bin/apps/integration_test/integration_test_cli compare \ + --before /private/tmp/valdi-integration-ios.json \ + --after /private/tmp/valdi-integration-web.json \ + --output-dir /private/tmp/valdi-integration-compare +``` + +The compare command writes: + +- `index.html`: interactive report +- `summary.json`: machine-readable summary +- `summary.md`: concise Markdown summary +- `before/`, `after/`, `diffs/`: decoded and generated PNGs + +Run both sides and compare in one command: + +```bash +bazel-bin/apps/integration_test/integration_test_cli full-comparison \ + --before-repo /path/to/repoA \ + --after-repo /path/to/repoB \ + --before-platform ios \ + --after-platform web \ + --output-dir /private/tmp/valdi-integration-full +``` + +Export snapshots from one result JSON into PNGs plus a contact sheet: + +```bash +bazel-bin/apps/integration_test/integration_test_cli export-snapshots \ + --result /private/tmp/valdi-integration-web.json \ + --output-dir /private/tmp/valdi-integration-web-snapshots +``` + +Run the CLI comparison self-test: + +```bash +bazel-bin/apps/integration_test/integration_test_cli self-test +``` + +## Web Harness Notes + +The web run path builds `//apps/integration_test:integration_test_web_npm` with web enabled, creates a temporary webpack harness under `/tmp`, serves it locally, and launches a Chrome-compatible browser in headless mode. + +If browser discovery fails, set `CHROME_BIN` to Chrome, Chromium, Chrome for Testing, or `chrome-headless-shell`. + +The web harness logs progress lines like: + +```text +[web progress] index=12 phase=snapshotting case=view-background-color captured=12 +``` + +Use those progress lines to locate renderer hangs. Avoid changing test cases just to hide a timeout; renderer bugs should usually be fixed in the renderer. + +## Native Run Notes + +Native `run` uses `valdi install` internally. Useful flags: + +- `--device-id`: simulator, emulator, or device id. Use `booted` for the current iOS simulator. +- `--ios-device-build`: build for a physical iOS device. +- `--bazel-args`: pass extra Bazel args through to `valdi install`. +- `--valdi-bin`: use a specific `valdi` executable. + +The app writes result JSON from inside the platform host. The CLI waits for completion, copies the result out, and terminates the app. + +## Comparison Expectations + +When validating a refactor, compare both summary metrics and generated diff PNGs if possible. A good smoke check is: + +- same `caseCount` +- same `changedCaseCount` +- same per-case `diffPercent`, `changedPixels`, `totalPixels`, and `dimensionMismatch` +- same diff PNG hashes when comparing an implementation rewrite against a known baseline + +Use `--pixel-threshold` for channel tolerance and `--fail-above` when the command should fail if max diff exceeds a percent threshold. + +## Editing Guidance + +- Add or update cases in `IntegrationTestCases.tsx`. +- Keep case ids stable; reports and comparisons key by id. +- Prefer recording platform limitations in observations over silently skipping behavior. +- If a snapshot can hang a platform, use the explicit skip/expected-failure mechanisms already present in the cases. +- Keep output under `/private/tmp` or another disposable directory; result sets and decoded images can be large. diff --git a/apps/integration_test/BUILD.bazel b/apps/integration_test/BUILD.bazel new file mode 100644 index 000000000..e81abba8e --- /dev/null +++ b/apps/integration_test/BUILD.bazel @@ -0,0 +1,31 @@ +load("//bzl/valdi:valdi_application.bzl", "valdi_application") +load("//bzl/valdi:valdi_cli_application.bzl", "valdi_cli_application") +load("//bzl/valdi:valdi_exported_library.bzl", "valdi_exported_library") + +valdi_application( + name = "integration_test", + android_package = "com.snap.valdi.integrationtest", + desktop_window_height = 720, + desktop_window_resizable = False, + desktop_window_width = 420, + ios_bundle_id = "com.snap.valdi.integrationtest", + ios_families = ["iphone"], + root_component_path = "IntegrationTestApp@integration_test_app/src/IntegrationTestApp", + title = "Valdi Integration Test", + version = "1.0.0", + deps = ["//apps/integration_test/src/valdi/integration_test_app"], +) + +valdi_cli_application( + name = "integration_test_cli", + script_path = "integration_test_cli/src/main", + deps = ["//apps/integration_test/src/valdi/integration_test_cli"], +) + +valdi_exported_library( + name = "integration_test_export", + ios_bundle_id = "com.snap.valdi.integrationtest.lib", + ios_bundle_name = "ValdiIntegrationTest", + web_package_name = "integration_test_web_npm", + deps = ["//apps/integration_test/src/valdi/integration_test_app"], +) diff --git a/apps/integration_test/src/android/BUILD.bazel b/apps/integration_test/src/android/BUILD.bazel new file mode 100644 index 000000000..da1125cd0 --- /dev/null +++ b/apps/integration_test/src/android/BUILD.bazel @@ -0,0 +1,13 @@ +load("//bzl/valdi:valdi_android_library.bzl", "valdi_android_library") + +valdi_android_library( + name = "integration_test_host_android", + srcs = glob([ + "**/*.kt", + ]), + visibility = ["//visibility:public"], + deps = [ + "//apps/integration_test/src/valdi/integration_test_app:integration_test_app_api_kt", + "//valdi:valdi_android_support", + ], +) diff --git a/apps/integration_test/src/android/IntegrationTestHostFactory.kt b/apps/integration_test/src/android/IntegrationTestHostFactory.kt new file mode 100644 index 000000000..958f4c61d --- /dev/null +++ b/apps/integration_test/src/android/IntegrationTestHostFactory.kt @@ -0,0 +1,239 @@ +package com.snap.valdi.integrationtest + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Typeface +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.widget.EditText +import com.snap.valdi.ViewFactory +import com.snap.valdi.attributes.AttributesBinder +import com.snap.valdi.attributes.AttributesBindingContext +import com.snap.valdi.context.ValdiContext +import com.snap.valdi.createViewFactory +import com.snap.valdi.modules.RegisterValdiModule +import com.snap.valdi.modules.integration_test_app.FactoryIntegrationHostModule +import com.snap.valdi.modules.integration_test_app.FactoryIntegrationHostModuleFactory +import com.snap.valdi.modules.integration_test_app.IntegrationTestHostModule +import com.snap.valdi.modules.integration_test_app.IntegrationTestHostModuleFactory +import com.snap.valdi.nodes.IValdiViewNode +import com.snap.valdi.nodes.ValdiViewNode +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.json.JSONObject +import java.io.File + +@RegisterValdiModule +class IntegrationTestHostFactory : IntegrationTestHostModuleFactory() { + override fun onLoadModule(): IntegrationTestHostModule { + return IntegrationTestHostModuleImpl() + } +} + +@RegisterValdiModule +class FactoryIntegrationHostFactory : FactoryIntegrationHostModuleFactory() { + override fun onLoadModule(): FactoryIntegrationHostModule { + return FactoryIntegrationHostModuleImpl() + } +} + +private class FactoryIntegrationHostModuleImpl : FactoryIntegrationHostModule { + override fun createIntegrationViewFactory(): ViewFactory { + val runtime = checkNotNull(ValdiContext.current()).runtime + return runtime.createViewFactory( + IntegrationFactoryView::class.java, + { context -> IntegrationFactoryView(context) }, + IntegrationFactoryAttributesBinder(), + ) + } +} + +private class IntegrationFactoryView(context: Context) : View(context) { + var factoryText: String? = null + set(value) { + field = value + invalidate() + } + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val path = Path() + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + val scale = resources.displayMetrics.density + val centerY = height / 2f + + path.reset() + path.moveTo(44f * scale, centerY - 26f * scale) + path.lineTo(66f * scale, centerY - 13f * scale) + path.lineTo(66f * scale, centerY + 13f * scale) + path.lineTo(44f * scale, centerY + 26f * scale) + path.lineTo(22f * scale, centerY + 13f * scale) + path.lineTo(22f * scale, centerY - 13f * scale) + path.close() + paint.color = Color.rgb(79, 70, 229) + canvas.drawPath(path, paint) + + path.reset() + path.moveTo(44f * scale, centerY - 16f * scale) + path.lineTo(49f * scale, centerY - 5f * scale) + path.lineTo(60f * scale, centerY) + path.lineTo(49f * scale, centerY + 5f * scale) + path.lineTo(44f * scale, centerY + 16f * scale) + path.lineTo(39f * scale, centerY + 5f * scale) + path.lineTo(28f * scale, centerY) + path.lineTo(39f * scale, centerY - 5f * scale) + path.close() + paint.color = Color.WHITE + canvas.drawPath(path, paint) + + paint.color = Color.rgb(23, 37, 84) + paint.textSize = 16f * scale + paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + val baseline = centerY - (paint.ascent() + paint.descent()) / 2f + canvas.drawText(factoryText.orEmpty(), 82f * scale, baseline, paint) + } +} + +private class IntegrationFactoryAttributesBinder : AttributesBinder { + override val viewClass = IntegrationFactoryView::class.java + + override fun bindAttributes(attributesBindingContext: AttributesBindingContext) { + attributesBindingContext.bindStringAttribute( + "factoryText", + false, + { view, value, _ -> view.factoryText = value }, + { view, _ -> view.factoryText = null }, + ) + } +} + +private class IntegrationTestHostModuleImpl : IntegrationTestHostModule { + private val mainHandler = Handler(Looper.getMainLooper()) + + override fun getPlatform(): String = "android" + + override fun getOutputPath(): String { + return "/data/data/com.snap.valdi.integrationtest/files/valdi-integration-test/results.json" + } + + override fun markFinished(path: String) { + // The TypeScript harness writes the result and .done sentinel through file_system. + } + + override fun writeTextFile(path: String, contents: String) { + val file = File(path) + file.parentFile?.mkdirs() + file.writeText(contents) + } + + override fun submitTouchSequence(node: IValdiViewNode, sequenceJson: String): String { + val target = getBackingView(node) ?: return "no backing Android View for ${node.viewClassName}" + val request = JSONObject(sequenceJson) + val events = request.optJSONArray("events") ?: return "no events in sequence" + val downTime = SystemClock.uptimeMillis() + val latch = CountDownLatch(1) + val errors = mutableListOf() + + mainHandler.post { + try { + var eventTime = downTime + for (i in 0 until events.length()) { + val event = events.getJSONObject(i) + eventTime += event.optLong("delayMs", 16) + val action = when (event.optString("action")) { + "down" -> MotionEvent.ACTION_DOWN + "move" -> MotionEvent.ACTION_MOVE + "up" -> MotionEvent.ACTION_UP + "cancel" -> MotionEvent.ACTION_CANCEL + else -> MotionEvent.ACTION_CANCEL + } + val x = (event.optDouble("x", 0.5) * target.width).toFloat() + val y = (event.optDouble("y", 0.5) * target.height).toFloat() + val motionEvent = MotionEvent.obtain(downTime, eventTime, action, x, y, 0) + try { + target.dispatchTouchEvent(motionEvent) + } finally { + motionEvent.recycle() + } + } + } catch (error: Throwable) { + errors.add(error.message ?: error.javaClass.name) + } finally { + latch.countDown() + } + } + + latch.await(2, TimeUnit.SECONDS) + return if (errors.isEmpty()) { + "dispatched ${events.length()} event(s) to ${target.javaClass.simpleName}" + } else { + "dispatch failed: ${errors.joinToString("; ")}" + } + } + + override fun focusTextInput(node: IValdiViewNode): String { + val editText = getBackingView(node) as? EditText ?: return "target is not EditText" + runOnMainSync { + editText.requestFocus() + } + return "focused ${editText.javaClass.simpleName}" + } + + override fun replaceText(node: IValdiViewNode, value: String): String { + val editText = getBackingView(node) as? EditText ?: return "target is not EditText" + runOnMainSync { + editText.setText(value) + editText.setSelection(value.length) + } + return "set text length=${value.length}" + } + + override fun pressReturn(node: IValdiViewNode): String { + val editText = getBackingView(node) as? EditText ?: return "target is not EditText" + runOnMainSync { + editText.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)) + editText.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER)) + } + return "sent enter key" + } + + override fun pressBackspace(node: IValdiViewNode): String { + val editText = getBackingView(node) as? EditText ?: return "target is not EditText" + runOnMainSync { + editText.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)) + editText.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL)) + } + return "sent delete key" + } + + private fun getBackingView(node: IValdiViewNode): View? { + val ref = (node as? ValdiViewNode)?.getBackingViewRef() + return ref?.get() as? View + } + + private fun runOnMainSync(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + return + } + + val latch = CountDownLatch(1) + mainHandler.post { + try { + block() + } finally { + latch.countDown() + } + } + latch.await(2, TimeUnit.SECONDS) + } +} diff --git a/apps/integration_test/src/cpp/BUILD.bazel b/apps/integration_test/src/cpp/BUILD.bazel new file mode 100644 index 000000000..90c7de65b --- /dev/null +++ b/apps/integration_test/src/cpp/BUILD.bazel @@ -0,0 +1,27 @@ +cc_library( + name = "integration_test_host_cpp", + srcs = ["IntegrationTestHost.cpp"], + hdrs = [], + includes = [], + visibility = ["//visibility:public"], + deps = [ + "//apps/integration_test/src/valdi/integration_test_app:integration_test_app_cpp", + "@valdi//valdi_core", + ], + alwayslink = 1, +) + +cc_library( + name = "integration_test_cli_cpp", + srcs = ["ImageDiffNative.cpp"], + copts = ["-Os"], + hdrs = [], + includes = [], + visibility = ["//visibility:public"], + deps = [ + "//apps/integration_test/src/valdi/integration_test_cli:integration_test_cli_cpp", + "//snap_drawing", + "@valdi//valdi_core", + ], + alwayslink = 1, +) diff --git a/apps/integration_test/src/cpp/ImageDiffNative.cpp b/apps/integration_test/src/cpp/ImageDiffNative.cpp new file mode 100644 index 000000000..19faece81 --- /dev/null +++ b/apps/integration_test/src/cpp/ImageDiffNative.cpp @@ -0,0 +1,264 @@ +#include "valdi_modules/integration_test_cli/integration_test_cli.hpp" + +#include "snap_drawing/cpp/Utils/Image.hpp" +#include "valdi_core/cpp/Interfaces/IBitmap.hpp" +#include "valdi_core/cpp/JavaScript/ModuleFactoryRegistry.hpp" +#include "valdi_core/cpp/Utils/BitmapWithBuffer.hpp" +#include "valdi_core/cpp/Utils/Bytes.hpp" +#include "valdi_core/cpp/Utils/Exception.hpp" +#include "valdi_core/cpp/Utils/Format.hpp" +#include "valdi_core/cpp/Utils/Shared.hpp" +#include "valdi_core/cpp/Utils/Value.hpp" + +#include +#include +#include + +namespace snap::valdi_modules::integration_test_cli { +namespace { + +constexpr uint8_t kDiffRed = 255; +constexpr uint8_t kDiffGreen = 0; +constexpr uint8_t kDiffBlue = 0; +constexpr uint8_t kDiffAlpha = 255; + +class LockedBitmapPixels { +public: + LockedBitmapPixels(Valdi::Ref bitmap, const char* name) : _bitmap(std::move(bitmap)), _name(name) { + if (_bitmap == nullptr) { + throw Valdi::Exception(STRING_FORMAT("{} bitmap is null", _name)); + } + + info = _bitmap->getInfo(); + if (info.width < 0 || info.height < 0) { + throw Valdi::Exception( + STRING_FORMAT("{} bitmap has invalid dimensions {}x{}", _name, info.width, info.height)); + } + if (info.colorType != Valdi::ColorTypeRGBA8888) { + throw Valdi::Exception(STRING_FORMAT( + "{} bitmap must be RGBA8888, got color type {}", _name, static_cast(info.colorType))); + } + if (info.rowBytes < static_cast(info.width) * 4) { + throw Valdi::Exception( + STRING_FORMAT("{} bitmap rowBytes {} is smaller than width * 4", _name, info.rowBytes)); + } + + bytes = static_cast(_bitmap->lockBytes()); + if (bytes == nullptr && info.width > 0 && info.height > 0) { + throw Valdi::Exception(STRING_FORMAT("Failed to lock {} bitmap pixels", _name)); + } + } + + ~LockedBitmapPixels() { + if (_bitmap != nullptr && bytes != nullptr) { + _bitmap->unlockBytes(); + } + } + + LockedBitmapPixels(const LockedBitmapPixels&) = delete; + LockedBitmapPixels& operator=(const LockedBitmapPixels&) = delete; + + bool contains(int x, int y) const { + return x >= 0 && y >= 0 && x < info.width && y < info.height; + } + + const uint8_t* pixel(int x, int y) const { + if (!contains(x, y)) { + return nullptr; + } + return bytes + static_cast(y) * info.rowBytes + static_cast(x) * 4; + } + + Valdi::BitmapInfo info = Valdi::BitmapInfo(0, 0, Valdi::ColorTypeRGBA8888, Valdi::AlphaTypeUnpremul, 0); + const uint8_t* bytes = nullptr; + +private: + Valdi::Ref _bitmap; + const char* _name; +}; + +uint8_t fadedChannel(uint8_t channel) { + return static_cast((static_cast(channel) * 55 + 255 * 45 + 50) / 100); +} + +bool pixelsEqual(const uint8_t* beforePixel, const uint8_t* afterPixel, int threshold) { + for (int channel = 0; channel < 4; channel++) { + const int beforeValue = beforePixel == nullptr ? 0 : beforePixel[channel]; + const int afterValue = afterPixel == nullptr ? 0 : afterPixel[channel]; + const int delta = beforeValue - afterValue; + if (delta > threshold || delta < -threshold) { + return false; + } + } + return true; +} + +void writeDiffPixel(uint8_t* outPixel) { + outPixel[0] = kDiffRed; + outPixel[1] = kDiffGreen; + outPixel[2] = kDiffBlue; + outPixel[3] = kDiffAlpha; +} + +void writeFadedPixel(uint8_t* outPixel, const uint8_t* afterPixel) { + const uint8_t red = afterPixel == nullptr ? 0 : afterPixel[0]; + const uint8_t green = afterPixel == nullptr ? 0 : afterPixel[1]; + const uint8_t blue = afterPixel == nullptr ? 0 : afterPixel[2]; + outPixel[0] = fadedChannel(red); + outPixel[1] = fadedChannel(green); + outPixel[2] = fadedChannel(blue); + outPixel[3] = afterPixel == nullptr ? 0 : afterPixel[3]; +} + +Valdi::Ref imageFromBytes(Valdi::BytesView data, const char* name) { + auto imageResult = snap::drawing::Image::make(data); + if (!imageResult) { + throw Valdi::Exception(imageResult.moveError()); + } + auto image = imageResult.value(); + if (image == nullptr) { + throw Valdi::Exception(STRING_FORMAT("Failed to decode {} image", name)); + } + return image; +} + +Valdi::Ref convertImageForComparison(const Valdi::Ref& image, + int width, + int height, + const char* name) { + auto bitmap = image->toConvertedBitmap(Valdi::BitmapInfo( + width, height, Valdi::ColorTypeRGBA8888, Valdi::AlphaTypeUnpremul, static_cast(width) * 4)); + if (bitmap == nullptr) { + throw Valdi::Exception(STRING_FORMAT("Failed to convert {} image for comparison", name)); + } + return bitmap; +} + +void diffSameSize(const LockedBitmapPixels& before, + const LockedBitmapPixels& after, + int width, + int height, + int threshold, + uint8_t* outBytes, + size_t outRowBytes, + double& changedPixels) { + for (int y = 0; y < height; y++) { + const uint8_t* beforeRow = before.bytes + static_cast(y) * before.info.rowBytes; + const uint8_t* afterRow = after.bytes + static_cast(y) * after.info.rowBytes; + uint8_t* outRow = outBytes + static_cast(y) * outRowBytes; + for (int x = 0; x < width; x++) { + const uint8_t* beforePixel = beforeRow + static_cast(x) * 4; + const uint8_t* afterPixel = afterRow + static_cast(x) * 4; + uint8_t* outPixel = outRow + static_cast(x) * 4; + if (!pixelsEqual(beforePixel, afterPixel, threshold)) { + changedPixels++; + writeDiffPixel(outPixel); + } else { + writeFadedPixel(outPixel, afterPixel); + } + } + } +} + +void diffWithBounds(const LockedBitmapPixels& before, + const LockedBitmapPixels& after, + int width, + int height, + int threshold, + uint8_t* outBytes, + size_t outRowBytes, + double& changedPixels) { + for (int y = 0; y < height; y++) { + uint8_t* outRow = outBytes + static_cast(y) * outRowBytes; + for (int x = 0; x < width; x++) { + const uint8_t* beforePixel = before.pixel(x, y); + const uint8_t* afterPixel = after.pixel(x, y); + uint8_t* outPixel = outRow + static_cast(x) * 4; + if (!pixelsEqual(beforePixel, afterPixel, threshold)) { + changedPixels++; + writeDiffPixel(outPixel); + } else { + writeFadedPixel(outPixel, afterPixel); + } + } + } +} + +NativeImageDiffResult diffComparableBitmaps(const Valdi::Ref& beforeBitmap, + const Valdi::Ref& afterBitmap, + bool dimensionMismatch, + double pixelThreshold) { + const LockedBitmapPixels before(beforeBitmap, "before"); + const LockedBitmapPixels after(afterBitmap, "after"); + const int width = std::max(before.info.width, after.info.width); + const int height = std::max(before.info.height, after.info.height); + const size_t rowBytes = static_cast(width) * 4; + const size_t byteLength = static_cast(height) * rowBytes; + + auto outBytes = Valdi::makeShared(); + outBytes->resize(byteLength); + uint8_t* out = outBytes->data(); + + double changedPixels = 0; + const int threshold = static_cast(pixelThreshold); + if (before.info.width == width && after.info.width == width && before.info.height == height && + after.info.height == height) { + diffSameSize(before, after, width, height, threshold, out, rowBytes, changedPixels); + } else { + diffWithBounds(before, after, width, height, threshold, out, rowBytes, changedPixels); + } + + NativeImageDiffResult result; + result.setChangedPixels(changedPixels); + result.setTotalPixels(static_cast(width) * height); + result.setDimensionMismatch(dimensionMismatch); + result.setImage(Valdi::Value(Valdi::makeShared( + Valdi::BytesView(outBytes), + Valdi::BitmapInfo(width, height, Valdi::ColorTypeRGBA8888, Valdi::AlphaTypeUnpremul, rowBytes)))); + return result; +} + +bool hasSameAspectRatio(const Valdi::Ref& beforeImage, + const Valdi::Ref& afterImage) { + return static_cast(beforeImage->width()) * afterImage->height() == + static_cast(afterImage->width()) * beforeImage->height(); +} + +} // namespace + +class ImageDiffNativeModuleImpl : public ImageDiffNativeModule { +public: + NativeImageDiffResult diffEncodedImages(Valdi::BytesView beforeData, + Valdi::BytesView afterData, + double pixelThreshold) { + auto beforeImage = imageFromBytes(beforeData, "before"); + auto afterImage = imageFromBytes(afterData, "after"); + const bool dimensionMismatch = + beforeImage->width() != afterImage->width() || beforeImage->height() != afterImage->height(); + int beforeWidth = beforeImage->width(); + int beforeHeight = beforeImage->height(); + int afterWidth = afterImage->width(); + int afterHeight = afterImage->height(); + if (dimensionMismatch && hasSameAspectRatio(beforeImage, afterImage)) { + beforeWidth = std::min(beforeImage->width(), afterImage->width()); + beforeHeight = std::min(beforeImage->height(), afterImage->height()); + afterWidth = beforeWidth; + afterHeight = beforeHeight; + } + + auto beforeBitmap = convertImageForComparison(beforeImage, beforeWidth, beforeHeight, "before"); + auto afterBitmap = convertImageForComparison(afterImage, afterWidth, afterHeight, "after"); + return diffComparableBitmaps(beforeBitmap, afterBitmap, dimensionMismatch, pixelThreshold); + } +}; + +class ImageDiffNativeModuleFactoryImpl : public ImageDiffNativeModuleFactory { +public: + Valdi::Ref onLoadModule() final { + return Valdi::makeShared(); + } +}; + +auto registerImageDiffNativeModule = Valdi::RegisterModuleFactory::registerTyped(); + +} // namespace snap::valdi_modules::integration_test_cli diff --git a/apps/integration_test/src/cpp/IntegrationTestHost.cpp b/apps/integration_test/src/cpp/IntegrationTestHost.cpp new file mode 100644 index 000000000..26d20011e --- /dev/null +++ b/apps/integration_test/src/cpp/IntegrationTestHost.cpp @@ -0,0 +1,63 @@ +#include "valdi_modules/integration_test_app/integration_test_app.hpp" + +#include "valdi_core/cpp/Utils/DiskUtils.hpp" +#include "valdi_core/cpp/Utils/Shared.hpp" + +#include + +namespace snap::valdi_modules::integration_test_app { + +class IntegrationTestHostModuleImpl : public IntegrationTestHostModule { +public: + Valdi::StringBox getPlatform() final { + return Valdi::StringBox::fromCString("macos"); + } + + Valdi::StringBox getOutputPath() final { + return Valdi::StringBox::fromCString("/tmp/valdi-integration-test/results.json"); + } + + void markFinished(Valdi::StringBox /*path*/) final {} + + void writeTextFile(Valdi::StringBox path, Valdi::StringBox contents) final { + Valdi::Path filePath(path.toStringView()); + if (filePath.getComponents().size() > 1) { + auto directory = filePath.removingLastComponent(); + if (!Valdi::DiskUtils::isDirectory(directory)) { + Valdi::DiskUtils::makeDirectory(directory, true); + } + } + Valdi::DiskUtils::store(filePath, contents.toStringView()); + } + + Valdi::StringBox submitTouchSequence(Valdi::Value /*node*/, Valdi::StringBox /*sequenceJson*/) final { + return Valdi::StringBox::fromCString("macos C++ host does not synthesize SnapDrawing touch input yet"); + } + + Valdi::StringBox focusTextInput(Valdi::Value /*node*/) final { + return Valdi::StringBox::fromCString("macos C++ host text focus not implemented"); + } + + Valdi::StringBox replaceText(Valdi::Value /*node*/, Valdi::StringBox value) final { + return Valdi::StringBox::fromString("macos C++ host accepted text length=" + std::to_string(value.length())); + } + + Valdi::StringBox pressReturn(Valdi::Value /*node*/) final { + return Valdi::StringBox::fromCString("macos C++ host return key not implemented"); + } + + Valdi::StringBox pressBackspace(Valdi::Value /*node*/) final { + return Valdi::StringBox::fromCString("macos C++ host backspace key not implemented"); + } +}; + +class IntegrationTestHostModuleFactoryImpl : public IntegrationTestHostModuleFactory { +public: + Valdi::Ref onLoadModule() final { + return Valdi::makeShared(); + } +}; + +auto registerIntegrationTestHostModule = Valdi::RegisterModuleFactory::registerTyped(); + +} // namespace snap::valdi_modules::integration_test_app diff --git a/apps/integration_test/src/ios/BUILD.bazel b/apps/integration_test/src/ios/BUILD.bazel new file mode 100644 index 000000000..8cabf7e44 --- /dev/null +++ b/apps/integration_test/src/ios/BUILD.bazel @@ -0,0 +1,14 @@ +objc_library( + name = "integration_test_host_ios", + srcs = glob([ + "**/*.m", + ]), + hdrs = [], + copts = ["-I."], + target_compatible_with = ["@platforms//os:ios"], + visibility = ["//visibility:public"], + deps = [ + "//apps/integration_test/src/valdi/integration_test_app:integration_test_app_api_objc", + "//valdi:valdi_ios", + ], +) diff --git a/apps/integration_test/src/ios/SCIntegrationTestHostFactory.m b/apps/integration_test/src/ios/SCIntegrationTestHostFactory.m new file mode 100644 index 000000000..33cefc48b --- /dev/null +++ b/apps/integration_test/src/ios/SCIntegrationTestHostFactory.m @@ -0,0 +1,252 @@ +#import "valdi/ios/Gestures/SCValdiGestureRecognizers.h" +#import "valdi/ios/SCValdiRuntimeManager.h" +#import "valdi_core/SCValdiAttributesBinderBase.h" +#import "valdi_core/SCValdiModuleFactoryRegistry.h" +#import "valdi_core/SCValdiRuntimeProtocol.h" +#import "valdi_core/SCValdiViewFactory.h" +#import "valdi_core/SCValdiViewNodeProtocol.h" +#import +#import +#import + +@interface SCIntegrationTestHost : NSObject +@end + +@implementation SCIntegrationTestHost + +- (NSString *)resolvePath:(NSString *)path +{ + if (path.isAbsolutePath) { + return path; + } + + NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); + NSString *documents = paths.firstObject ?: NSTemporaryDirectory(); + return [[documents stringByAppendingPathComponent:@"Valdi"] stringByAppendingPathComponent:path]; +} + +- (NSString *)getPlatform +{ + return @"ios"; +} + +- (NSString *)getOutputPath +{ + return @"valdi-integration-test/results.json"; +} + +- (void)markFinishedWithPath:(NSString *)path +{ + NSString *resolvedPath = [self resolvePath:path]; + NSString *directory = resolvedPath.stringByDeletingLastPathComponent; + [NSFileManager.defaultManager createDirectoryAtPath:directory withIntermediateDirectories:YES attributes:nil error:nil]; + [@"done" writeToFile:resolvedPath atomically:YES encoding:NSUTF8StringEncoding error:nil]; +} + +- (void)writeTextFileWithPath:(NSString *)path contents:(NSString *)contents +{ + NSString *resolvedPath = [self resolvePath:path]; + NSString *directory = resolvedPath.stringByDeletingLastPathComponent; + [NSFileManager.defaultManager createDirectoryAtPath:directory withIntermediateDirectories:YES attributes:nil error:nil]; + [contents writeToFile:resolvedPath atomically:YES encoding:NSUTF8StringEncoding error:nil]; +} + +- (NSString *)submitTouchSequenceWithNode:(id)node sequenceJson:(NSString *)sequenceJson +{ + UIView *view = node.view; + if (!view) { + return @"no backing UIView"; + } + + NSData *data = [sequenceJson dataUsingEncoding:NSUTF8StringEncoding]; + NSDictionary *request = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil; + NSString *kind = [request[@"kind"] isKindOfClass:NSString.class] ? request[@"kind"] : @"tap"; + CGPoint location = CGPointMake(CGRectGetMidX(view.bounds), CGRectGetMidY(view.bounds)); + NSUInteger triggered = 0; + + for (UIGestureRecognizer *recognizer in view.gestureRecognizers) { + if ([kind isEqualToString:@"doubleTap"] && [recognizer isKindOfClass:SCValdiFastDoubleTapGestureRecognizer.class]) { + [(SCValdiFastDoubleTapGestureRecognizer *)recognizer triggerAtLocation:location forState:UIGestureRecognizerStateEnded]; + triggered++; + } else if ([kind isEqualToString:@"longPress"] && [recognizer isKindOfClass:SCValdiLongPressGestureRecognizer.class]) { + [(SCValdiLongPressGestureRecognizer *)recognizer triggerAtLocation:location forState:UIGestureRecognizerStateBegan]; + triggered++; + } else if ([recognizer isKindOfClass:SCValdiTapGestureRecognizer.class]) { + [(SCValdiTapGestureRecognizer *)recognizer triggerAtLocation:location forState:UIGestureRecognizerStateEnded]; + triggered++; + } + } + + if (triggered == 0) { + return [NSString stringWithFormat:@"no triggerable recognizer for %@ on %@", kind, NSStringFromClass(view.class)]; + } + return [NSString stringWithFormat:@"triggered %lu recognizer(s) for %@ on %@", (unsigned long)triggered, kind, NSStringFromClass(view.class)]; +} + +- (NSString *)focusTextInputWithNode:(id)node +{ + UIView *view = node.view; + if ([view respondsToSelector:@selector(becomeFirstResponder)]) { + [view becomeFirstResponder]; + return [NSString stringWithFormat:@"focused %@", NSStringFromClass(view.class)]; + } + return @"target cannot become first responder"; +} + +- (NSString *)replaceTextWithNode:(id)node value:(NSString *)value +{ + UIView *view = node.view; + if ([view isKindOfClass:UITextField.class]) { + UITextField *textField = (UITextField *)view; + textField.text = value; + [textField sendActionsForControlEvents:UIControlEventEditingChanged]; + return [NSString stringWithFormat:@"set UITextField text length=%lu", (unsigned long)value.length]; + } + if ([view isKindOfClass:UITextView.class]) { + ((UITextView *)view).text = value; + return [NSString stringWithFormat:@"set UITextView text length=%lu", (unsigned long)value.length]; + } + return [NSString stringWithFormat:@"target is not editable text: %@", NSStringFromClass(view.class)]; +} + +- (NSString *)pressReturnWithNode:(id)node +{ + UIView *view = node.view; + if ([view isKindOfClass:UITextField.class]) { + UITextField *textField = (UITextField *)view; + [textField sendActionsForControlEvents:UIControlEventEditingDidEndOnExit]; + return @"sent UITextField return"; + } + return [NSString stringWithFormat:@"return key unsupported for %@", NSStringFromClass(view.class)]; +} + +- (NSString *)pressBackspaceWithNode:(id)node +{ + UIView *view = node.view; + if ([view isKindOfClass:UITextField.class]) { + UITextField *textField = (UITextField *)view; + NSString *text = textField.text ?: @""; + textField.text = text.length > 0 ? [text substringToIndex:text.length - 1] : text; + [textField sendActionsForControlEvents:UIControlEventEditingChanged]; + return @"sent UITextField backspace"; + } + if ([view isKindOfClass:UITextView.class]) { + UITextView *textView = (UITextView *)view; + NSString *text = textView.text ?: @""; + textView.text = text.length > 0 ? [text substringToIndex:text.length - 1] : text; + return @"sent UITextView backspace"; + } + return [NSString stringWithFormat:@"backspace unsupported for %@", NSStringFromClass(view.class)]; +} + +@end + +@interface SCIntegrationTestHostFactory : SCCIntegrationTestAppIntegrationTestHostModuleFactory +@end + +@implementation SCIntegrationTestHostFactory + +VALDI_REGISTER_MODULE() + +- (id)onLoadModule +{ + return [SCIntegrationTestHost new]; +} + +@end + +@interface SCIntegrationTestFactoryView : UIView + +@property (nonatomic, copy, nullable) NSString *factoryText; + +@end + +@implementation SCIntegrationTestFactoryView + +- (void)setFactoryText:(NSString *)factoryText +{ + _factoryText = [factoryText copy]; + [self setNeedsDisplay]; +} + +- (void)drawRect:(CGRect)rect +{ + CGFloat centerY = CGRectGetMidY(rect); + + UIBezierPath *hexagon = [UIBezierPath bezierPath]; + [hexagon moveToPoint:CGPointMake(44, centerY - 26)]; + [hexagon addLineToPoint:CGPointMake(66, centerY - 13)]; + [hexagon addLineToPoint:CGPointMake(66, centerY + 13)]; + [hexagon addLineToPoint:CGPointMake(44, centerY + 26)]; + [hexagon addLineToPoint:CGPointMake(22, centerY + 13)]; + [hexagon addLineToPoint:CGPointMake(22, centerY - 13)]; + [hexagon closePath]; + [[UIColor colorWithRed:79.0 / 255.0 green:70.0 / 255.0 blue:229.0 / 255.0 alpha:1] setFill]; + [hexagon fill]; + + UIBezierPath *sparkle = [UIBezierPath bezierPath]; + [sparkle moveToPoint:CGPointMake(44, centerY - 16)]; + [sparkle addLineToPoint:CGPointMake(49, centerY - 5)]; + [sparkle addLineToPoint:CGPointMake(60, centerY)]; + [sparkle addLineToPoint:CGPointMake(49, centerY + 5)]; + [sparkle addLineToPoint:CGPointMake(44, centerY + 16)]; + [sparkle addLineToPoint:CGPointMake(39, centerY + 5)]; + [sparkle addLineToPoint:CGPointMake(28, centerY)]; + [sparkle addLineToPoint:CGPointMake(39, centerY - 5)]; + [sparkle closePath]; + [UIColor.whiteColor setFill]; + [sparkle fill]; + + UIFont *font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold]; + CGRect textRect = CGRectMake(82, floor(centerY - font.lineHeight / 2), CGRectGetWidth(rect) - 82, font.lineHeight); + [self.factoryText drawInRect:textRect withAttributes:@{ + NSFontAttributeName: font, + NSForegroundColorAttributeName: [UIColor colorWithRed:23.0 / 255.0 green:37.0 / 255.0 blue:84.0 / 255.0 alpha:1], + }]; +} + +@end + +@interface SCFactoryIntegrationHost : NSObject +@end + +@implementation SCFactoryIntegrationHost + +- (id)createIntegrationViewFactory +{ + id runtime = SCValdiRuntimeManager.allRuntimeManagers.firstObject.mainRuntime; + NSAssert(runtime != nil, @"The integration app must have an active Valdi runtime"); + + return [runtime makeViewFactoryWithBlock:^UIView *{ + SCIntegrationTestFactoryView *view = [SCIntegrationTestFactoryView new]; + view.opaque = NO; + view.accessibilityIdentifier = @"integration-factory-view"; + return view; + } attributesBinder:^(id binder) { + [binder bindAttribute:@"factoryText" + invalidateLayoutOnChange:NO + withStringBlock:^BOOL(SCIntegrationTestFactoryView *view, NSString *value, id animator) { + view.factoryText = value; + return YES; + } + resetBlock:^(SCIntegrationTestFactoryView *view, id animator) { + view.factoryText = nil; + }]; + } forClass:SCIntegrationTestFactoryView.class]; +} + +@end + +@interface SCFactoryIntegrationHostFactory : SCCIntegrationTestAppFactoryIntegrationHostModuleFactory +@end + +@implementation SCFactoryIntegrationHostFactory + +VALDI_REGISTER_MODULE() + +- (id)onLoadModule +{ + return [SCFactoryIntegrationHost new]; +} + +@end diff --git a/apps/integration_test/src/valdi/integration_test_app/BUILD.bazel b/apps/integration_test/src/valdi/integration_test_app/BUILD.bazel new file mode 100644 index 000000000..1011405e4 --- /dev/null +++ b/apps/integration_test/src/valdi/integration_test_app/BUILD.bazel @@ -0,0 +1,59 @@ +load("@aspect_rules_ts//ts:defs.bzl", "ts_project") +load("//bzl/valdi:valdi_module.bzl", "valdi_module") + +ts_project( + name = "integration_test_app_web", + srcs = glob([ + "web/**/*.ts", + "src/**/*.d.ts", + ]), + allow_js = True, + composite = True, + transpiler = "tsc", + tsconfig = "web/tsconfig.json", + deps = [ + "//:valdi_core_link", + "//:valdi_tsx_link", + "//:web_renderer_link", + ], +) + +valdi_module( + name = "integration_test_app", + srcs = glob([ + "src/**/*.ts", + "src/**/*.tsx", + ]), + android_class_path = "com.snap.valdi.modules.integration_test_app", + android_deps = ["//apps/integration_test/src/android:integration_test_host_android"], + android_output_target = "release", + ios_deps = ["//apps/integration_test/src/ios:integration_test_host_ios"], + ios_module_name = "SCCIntegrationTestApp", + ios_output_target = "release", + inline_assets = True, + macos_deps = ["//apps/integration_test/src/cpp:integration_test_host_cpp"], + res = glob([ + "res/**/*.json", + "res/**/*.svg", + ]), + visibility = ["//visibility:public"], + web_deps = [":integration_test_app_web"], + # Exercise external Web asset emission in the integration harness. Other + # examples, including Hello World, retain the default inline policy. + web_no_inline_images = True, + web_register_native_module_id_overrides = { + "integration_test_app/web/FactoryIntegrationHost.js": "integration_test_app/src/FactoryIntegrationHost", + "integration_test_app/web/IntegrationTestHost.js": "integration_test_app/src/IntegrationTestHost", + }, + web_workers = ["integration_test_app/src/WebWorkerProbe"], + deps = [ + "//src/valdi_modules/src/valdi/coreutils", + "//src/valdi_modules/src/valdi/drawing", + "//src/valdi_modules/src/valdi/file_system", + "//src/valdi_modules/src/valdi/valdi_core", + "//src/valdi_modules/src/valdi/valdi_tsx", + "//src/valdi_modules/src/valdi/valdi_webview", + "//src/valdi_modules/src/valdi/web_renderer", + "//src/valdi_modules/src/valdi/worker", + ], +) diff --git a/apps/integration_test/src/valdi/integration_test_app/res/animation.json b/apps/integration_test/src/valdi/integration_test_app/res/animation.json new file mode 100644 index 000000000..99b0ebc56 --- /dev/null +++ b/apps/integration_test/src/valdi/integration_test_app/res/animation.json @@ -0,0 +1,52 @@ +{ + "v": "5.7.4", + "fr": 30, + "ip": 0, + "op": 60, + "w": 120, + "h": 120, + "nm": "ValdiIntegrationFixture", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "circle", + "sr": 1, + "ks": { + "o": {"a": 0, "k": 100}, + "r": {"a": 0, "k": 0}, + "p": {"a": 0, "k": [60, 60, 0]}, + "a": {"a": 0, "k": [0, 0, 0]}, + "s": { + "a": 1, + "k": [ + {"t": 0, "s": [70, 70, 100]}, + {"t": 30, "s": [100, 100, 100]}, + {"t": 60, "s": [70, 70, 100]} + ] + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + {"ty": "el", "p": {"a": 0, "k": [0, 0]}, "s": {"a": 0, "k": [80, 80]}, "nm": "ellipse"}, + {"ty": "fl", "c": {"a": 0, "k": [0.0, 0.48, 1.0, 1]}, "o": {"a": 0, "k": 100}, "nm": "fill"}, + {"ty": "st", "c": {"a": 0, "k": [1.0, 0.8, 0.0, 1]}, "o": {"a": 0, "k": 100}, "w": {"a": 0, "k": 8}, "lc": 2, "lj": 2, "nm": "stroke"}, + {"ty": "tr", "p": {"a": 0, "k": [0, 0]}, "a": {"a": 0, "k": [0, 0]}, "s": {"a": 0, "k": [100, 100]}, "r": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100}} + ], + "nm": "group" + } + ], + "ip": 0, + "op": 60, + "st": 0, + "bm": 0 + } + ], + "markers": [] +} diff --git a/apps/integration_test/src/valdi/integration_test_app/res/image.svg b/apps/integration_test/src/valdi/integration_test_app/res/image.svg new file mode 100644 index 000000000..6bd09ffe5 --- /dev/null +++ b/apps/integration_test/src/valdi/integration_test_app/res/image.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/integration_test/src/valdi/integration_test_app/res/tint_mask.svg b/apps/integration_test/src/valdi/integration_test_app/res/tint_mask.svg new file mode 100644 index 000000000..7dd3a281e --- /dev/null +++ b/apps/integration_test/src/valdi/integration_test_app/res/tint_mask.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/integration_test/src/valdi/integration_test_app/src/FactoryIntegrationHost.d.ts b/apps/integration_test/src/valdi/integration_test_app/src/FactoryIntegrationHost.d.ts new file mode 100644 index 000000000..c3aea55d4 --- /dev/null +++ b/apps/integration_test/src/valdi/integration_test_app/src/FactoryIntegrationHost.d.ts @@ -0,0 +1,8 @@ +import { ViewFactory } from 'valdi_tsx/src/ViewFactory'; + +/** + * @ExportModule + */ + +// @ExportFunction +export function createIntegrationViewFactory(): ViewFactory; diff --git a/apps/integration_test/src/valdi/integration_test_app/src/IntegrationTestApp.tsx b/apps/integration_test/src/valdi/integration_test_app/src/IntegrationTestApp.tsx new file mode 100644 index 000000000..dedc78fd8 --- /dev/null +++ b/apps/integration_test/src/valdi/integration_test_app/src/IntegrationTestApp.tsx @@ -0,0 +1,192 @@ +import { StatefulComponent } from 'valdi_core/src/Component'; +import { Device } from 'valdi_core/src/Device'; +import { ElementRef } from 'valdi_core/src/ElementRef'; + +import { INTEGRATION_TEST_CASES } from './IntegrationTestCases'; +import { + CaptureOutcome, + IntegrationTestRunner, + SNAPSHOT_HEIGHT, + SNAPSHOT_WIDTH, +} from './IntegrationTestRunner'; + +/** + * @ViewModel + * @ExportModel + */ +export interface ViewModel {} + +/** + * @Context + * @ExportModel + */ +export interface ComponentContext {} + +interface State { + currentIndex: number; + finished: boolean; + summary: string; + insetTop: number; + insetRight: number; + insetBottom: number; + insetLeft: number; +} + +function readShellInsets(): Pick { + return { + insetTop: Device.getDisplayTopInset(), + insetRight: Device.getDisplayRightInset(), + insetBottom: Device.getDisplayBottomInset(), + insetLeft: Device.getDisplayLeftInset(), + }; +} + +/** + * @Component + * @ExportModel + */ +export class IntegrationTestApp extends StatefulComponent { + state: State = { + currentIndex: 0, + finished: false, + summary: 'starting', + ...readShellInsets(), + }; + + private rootRef = new ElementRef(); + private snapshotRef = new ElementRef(); + private targetRef = new ElementRef(); + private runner?: IntegrationTestRunner; + private hasStarted = false; + private scheduledCaptureCaseId?: string; + + onCreate(): void { + this.runner = new IntegrationTestRunner(this.renderer); + + const insetsObserver = Device.observeDisplayInsetChange(() => { + this.setState(readShellInsets()); + }); + this.registerDisposable(() => insetsObserver.cancel()); + } + + private getRunner(): IntegrationTestRunner { + if (!this.runner) { + this.runner = new IntegrationTestRunner(this.renderer); + } + return this.runner; + } + + private async captureCurrentCase(): Promise { + const runner = this.getRunner(); + this.hasStarted = true; + const outcome = await runner.captureCurrentCase({ + currentIndex: this.state?.currentIndex ?? 0, + isFinished: this.state?.finished ?? false, + rootRef: this.rootRef, + snapshotRef: this.snapshotRef, + targetRef: this.targetRef, + }); + this.applyCaptureOutcome(outcome); + } + + private applyCaptureOutcome(outcome: CaptureOutcome): void { + if (outcome.kind === 'noop') { + return; + } + + if (outcome.kind === 'finished') { + this.setState({ + finished: true, + summary: outcome.summary, + }); + return; + } + + this.rootRef = new ElementRef(); + this.snapshotRef = new ElementRef(); + this.targetRef = new ElementRef(); + this.setState({ + currentIndex: outcome.nextIndex, + summary: outcome.summary, + }); + } + + private scheduleCaptureForRenderedCase(caseId: string): void { + if (this.scheduledCaptureCaseId === caseId) { + return; + } + + this.scheduledCaptureCaseId = caseId; + this.renderer.onLayoutComplete(() => { + const currentIndex = this.state?.currentIndex ?? 0; + const currentCase = INTEGRATION_TEST_CASES[currentIndex]; + if (!this.state?.finished && currentCase?.id === caseId) { + void this.captureCurrentCase(); + } + }); + } + + onRender(): void { + const currentIndex = this.state?.currentIndex ?? 0; + const testCase = INTEGRATION_TEST_CASES[currentIndex]; + const insetTop = this.state?.insetTop ?? 0; + const insetRight = this.state?.insetRight ?? 0; + const insetBottom = this.state?.insetBottom ?? 0; + const insetLeft = this.state?.insetLeft ?? 0; + + if (this.state?.finished || !testCase) { + + ; + return; + } + + const runner = this.getRunner(); + runner.prepareCase(testCase.id); + + + + {testCase.render({ + caseId: testCase.id, + rootRef: this.rootRef, + targetRef: this.targetRef, + record: (message: string) => runner.record(message), + })} + + ; + + // Schedule after emitting the test body so the layout callback observes this render. + this.scheduleCaptureForRenderedCase(testCase.id); + } +} diff --git a/apps/integration_test/src/valdi/integration_test_app/src/IntegrationTestCases.tsx b/apps/integration_test/src/valdi/integration_test_app/src/IntegrationTestCases.tsx new file mode 100644 index 000000000..bf1943cea --- /dev/null +++ b/apps/integration_test/src/valdi/integration_test_app/src/IntegrationTestCases.tsx @@ -0,0 +1,5589 @@ +import { FontManager } from 'drawing/src/FontManager'; +import { Base64 } from 'coreutils/src/Base64'; +import { GeometricPathBuilder, GeometricPathScaleType } from 'valdi_core/src/GeometricPath'; +import { Component, StatefulComponent } from 'valdi_core/src/Component'; +import { Style } from 'valdi_core/src/Style'; +import type { Asset } from 'valdi_core/src/Asset'; +import { ValdiRuntime } from 'valdi_core/src/ValdiRuntime'; +import { AttributedTextBuilder } from 'valdi_core/src/utils/AttributedTextBuilder'; +import { ImageFilters } from 'valdi_core/src/utils/ImageFilter'; +import { AttributedTextInlineViewVerticalAlignment } from 'valdi_tsx/src/AttributedTextInlineViewAttachment'; +import type { ViewFactory } from 'valdi_tsx/src/ViewFactory'; +import { Worker } from 'worker/src/Worker'; +import type { + AnimatedImage, + BlurView, + ImageView, + IWebViewNativeController, + Label, + Layout, + ShapeView, + SpinnerView, + TextField, + TextView, + View, + WebViewElement, +} from 'valdi_tsx/src/NativeTemplateElements'; + +import res from '../res'; +import { createIntegrationViewFactory } from './FactoryIntegrationHost'; +import { + getPlatform, + submitTouchSequence, + focusTextInput, + pressReturn, + replaceText, + pressBackspace, +} from './IntegrationTestHost'; +import { + IntegrationTestAttributeCoverage, + IntegrationTestCase, + IntegrationTestCoverageKind, + IntegrationTestInteractionContext, + IntegrationTestRenderContext, + NativeTemplateElementName, +} from './IntegrationTestTypes'; + +declare const runtime: ValdiRuntime; + +const WIDTH = 360; +const HEIGHT = 560; +const CARD = '#F8FAFC'; +const ANIMATED_IMAGE_PNG_DATA_URL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHgAAABQCAYAAADSm7GJAAABiUlEQVR4nO3TsU0FMRQF0W2BjA5ICciohFZokISelgqQ/n7be98bz5UmXa985OP8fD936uPna6uO9IULLLDAAgsssMB7lL5wgQUWWGCBBRZ4jwSGJzA8geEJDE9geALDExiewPDQwC+vbw+VRhB4Aeou2BjgUVgqdHvg2bA06NbAq3EJyC2B74IlQLcDTuF2RW4FnMbtiCywwDVKo3ZFbgE8ivHfdkDGAl+dwGBcMjIOeHQCg3GJyBjg2RO4EPCqCVwAtwJwZWSBBa4NvHoCCyywwEHg4/s8K9YN+Pw9SiawwAILHAReiXzpHwpgCixwrboApxHxwCuQCa8XBTwT+fK5BSBbAieQSbhI4BHkp84qgNga+C5kIi4a+BHs4e8WAEQAz0CeXRoOB1wJOY0msMA9gSsgp8HwwEnkNNY2wHdDp5G2Bb4DOQ20PfAq6DSMwIug0yACL8BOIwg8sfSFCyywwAILLLDAeyQwPIHhCQxPYHgCwxMYnsDwBIYnMDyB4QkMT2B4AsP7AzsXR9oA2ei4AAAAAElFTkSuQmCC'; + +const INLINE_IMAGE_BYTES = pngDataUrlBytes(ANIMATED_IMAGE_PNG_DATA_URL); + +let integrationViewFactory: ViewFactory | undefined; + +function pngDataUrlBytes(dataUrl: string): Uint8Array { + return Base64.toByteArray(dataUrl.substring(dataUrl.indexOf(',') + 1)); +} + +function coverage( + kind: IntegrationTestCoverageKind, + attributes: readonly string[], +): readonly IntegrationTestAttributeCoverage[] { + return [{ kind, attributes }]; +} + +function configureIntegrationColorPalettes(): void { + runtime.configureColorPalette('integration-light', { + background: '#DBEAFE', + foreground: '#1D4ED8', + accent: '#047857', + }); + runtime.configureColorPalette('integration-dark', { + background: '#111827', + foreground: '#FBBF24', + accent: '#F97316', + }); + runtime.setActiveColorPalette('integration-light'); +} + +function lottieResourceSource(ctx: IntegrationTestRenderContext): Asset | string { + try { + const bytes = runtime.getModuleEntry('integration_test_app', 'res/animation.json', false) as Uint8Array; + ctx.record(`lottie resource bytes:${bytes.length}`); + return runtime.makeAssetFromBytes(bytes); + } catch (error: any) { + ctx.record(`lottie resource unavailable:${error?.message ?? String(error)}`); + return 'integration-test-missing-lottie.json'; + } +} + +const styleLayoutBase = new Style({ + padding: 12, + width: '100%', +}); + +const styleViewCard = new Style({ + backgroundColor: '#DBEAFE', + border: '2 solid #2563EB', + borderRadius: 12, + height: 92, + width: '100%', +}); + +const styleLabelSample = new Style