Skip to content
Open
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
13 changes: 12 additions & 1 deletion .github/workflows/ci-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,18 @@ jobs:
target: google_apis
arch: x86
disable-animations: true
script: npm run ${{ matrix.test-command }}
# connectedAndroidTest is included here (rather than a separate job or step) to reuse
# this job's already-booted emulator, even though it means it runs once per matrix
# variant. The android-emulator-runner action has no post-cleanup step, so a second
# step would boot and tear down a second emulator; ::group:: markers keep the two
# test runs visually separated in the Actions log instead.
script: |
echo "::group::Instrumented tests"
(cd android && ./gradlew :app:connectedAndroidTest)
echo "::endgroup::"
echo "::group::E2E tests"
npm run ${{ matrix.test-command }}
echo "::endgroup::"

ios-test:
needs: lint
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,9 @@ Examples/testapp_rn
# Android debug build files (conflict ignoring #Visual Studio files)
!android/app/src/debug/

# CMake/NDK build cache for android/app/src/main/cpp
android/app/.cxx

# iOS Simulator crash reports swept up from failed test runs (see test/test.ts)
test/crash-logs/

Expand Down
5 changes: 4 additions & 1 deletion .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@ test/
# Remove after this framework is published on NPM
code-push-plugin-testing-framework/

# Android build artifacts and Android Studio bits
# Android build artifacts, test sources and Android Studio bits
android/app/build
android/app/.cxx
android/app/src/test
android/app/src/androidTest
android/local.properties
android/.gradle
android/**/*.iml
Expand Down
19 changes: 19 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ android {
versionCode 1
versionName "1.0"
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", IS_NEW_ARCHITECTURE_ENABLED.toString()

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

externalNativeBuild {
cmake {
abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
}
}
}

externalNativeBuild {
cmake {
path "src/main/cpp/CMakeLists.txt"
version "3.22.1"
}
}

lintOptions {
Expand Down Expand Up @@ -81,4 +96,8 @@ dependencies {
implementation 'com.nimbusds:nimbus-jose-jwt:9.37.3'

testImplementation 'junit:junit:4.13.2'

androidTestImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test:runner:1.6.2'
}
2 changes: 2 additions & 0 deletions android/app/src/androidTest/assets/bad_header/old.dat
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Irrelevant content - old.dat just needs to open successfully so the
test reaches the diff-header check this fixture is actually exercising.
Binary file not shown.
25 changes: 25 additions & 0 deletions android/app/src/androidTest/assets/basic/new.dat
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
function greet(name) {
console.log("Hello there, " + name + "!");
return "Hello there, " + name + "!";
}

function farewell(name) {
console.log("Goodbye, " + name + ".");
return "Goodbye, " + name + ".";
}

function shout(name) {
console.log("HEY, " + name.toUpperCase() + "!!!");
return "HEY, " + name.toUpperCase() + "!!!";
}

var VERSION = "1.1.0";
var BUILD_NUMBER = 43;

module.exports = {
greet: greet,
farewell: farewell,
shout: shout,
VERSION: VERSION,
BUILD_NUMBER: BUILD_NUMBER,
};
19 changes: 19 additions & 0 deletions android/app/src/androidTest/assets/basic/old.dat
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
function greet(name) {
console.log("Hello, " + name + "!");
return "Hello, " + name + "!";
}

function farewell(name) {
console.log("Goodbye, " + name + ".");
return "Goodbye, " + name + ".";
}

var VERSION = "1.0.0";
var BUILD_NUMBER = 42;

module.exports = {
greet: greet,
farewell: farewell,
VERSION: VERSION,
BUILD_NUMBER: BUILD_NUMBER,
};
Binary file not shown.
3 changes: 3 additions & 0 deletions android/app/src/androidTest/assets/empty_old/new.dat
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Everything in this file is new: the old side is a zero-byte file, so
the whole patch body is a literal insert with no copy-from-old control
entries at all.
Empty file.
Binary file not shown.
4 changes: 4 additions & 0 deletions android/app/src/androidTest/assets/identical/new.dat
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
This file is byte-for-byte identical on both sides of the patch.
It exercises the zero-delta path: a real BSDIFF40 patch whose only
control entry is a single full-length copy from the old file, with
no literal bytes and no seek. Nothing to add or skip.
4 changes: 4 additions & 0 deletions android/app/src/androidTest/assets/identical/old.dat
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
This file is byte-for-byte identical on both sides of the patch.
It exercises the zero-delta path: a real BSDIFF40 patch whose only
control entry is a single full-length copy from the old file, with
no literal bytes and no seek. Nothing to add or skip.
Binary file not shown.
3 changes: 3 additions & 0 deletions android/app/src/androidTest/assets/wrong_old/old.dat
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
This is a completely unrelated old file, deliberately shaped so that
applying fixtures/basic/patch.bsdiff against it does not match the old
file bsdiff was actually built from.
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package com.microsoft.codepush.react.diffpatch

import android.content.res.AssetManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import java.io.File

@RunWith(AndroidJUnit4::class)
class DiffPatchInstrumentedTest {

@get:Rule
val tempFolder = TemporaryFolder()

private lateinit var assets: AssetManager

@Before
fun setUp() {
assets = InstrumentationRegistry.getInstrumentation().context.assets
}

// Assets are packed inside the APK, not addressable as filesystem paths, but
// DiffPatch.applyPatch() takes real file paths. So each fixture has to be
// copied out to a real file before it can be passed in.
private fun copyAssetToFile(assetPath: String, destination: File) {
assets.open(assetPath).use { input ->
destination.outputStream().use { output -> input.copyTo(output) }
}
}

private fun newFileFor(assetPath: String): File =
File(tempFolder.newFolder(), File(assetPath).name)

@Test
fun applyPatch_basicDiff_succeedsAndMatchesExpectedOutput() {
// An ordinary text-file diff, several inserted/changed/copied regions.
val oldFile = newFileFor("basic/old.dat").also { copyAssetToFile("basic/old.dat", it) }
val diffFile = newFileFor("basic/patch.bsdiff").also { copyAssetToFile("basic/patch.bsdiff", it) }
val expectedNewFile = newFileFor("basic/new.dat").also { copyAssetToFile("basic/new.dat", it) }
val outFile = File(tempFolder.root, "basic_out.dat")

val result = DiffPatch.applyPatch(oldFile.absolutePath, diffFile.absolutePath, outFile.absolutePath)

assertEquals(DiffPatch.PatchResult.OK, result)
assertArrayEquals(expectedNewFile.readBytes(), outFile.readBytes())
}

@Test
fun applyPatch_identicalOldAndNew_succeeds() {
// Real BSDIFF40 patch whose only control entry is a single full-length copy from the old file.
val oldFile = newFileFor("identical/old.dat").also { copyAssetToFile("identical/old.dat", it) }
val diffFile = newFileFor("identical/patch.bsdiff").also { copyAssetToFile("identical/patch.bsdiff", it) }
val expectedNewFile = newFileFor("identical/new.dat").also { copyAssetToFile("identical/new.dat", it) }
val outFile = File(tempFolder.root, "identical_out.dat")

val result = DiffPatch.applyPatch(oldFile.absolutePath, diffFile.absolutePath, outFile.absolutePath)

assertEquals(DiffPatch.PatchResult.OK, result)
assertArrayEquals(expectedNewFile.readBytes(), outFile.readBytes())
}

@Test
fun applyPatch_emptyOldFile_succeeds() {
val oldFile = newFileFor("empty_old/old.dat").also { copyAssetToFile("empty_old/old.dat", it) }
val diffFile = newFileFor("empty_old/patch.bsdiff").also { copyAssetToFile("empty_old/patch.bsdiff", it) }
val expectedNewFile = newFileFor("empty_old/new.dat").also { copyAssetToFile("empty_old/new.dat", it) }
val outFile = File(tempFolder.root, "empty_old_out.dat")

val result = DiffPatch.applyPatch(oldFile.absolutePath, diffFile.absolutePath, outFile.absolutePath)

assertEquals(DiffPatch.PatchResult.OK, result)
assertArrayEquals(expectedNewFile.readBytes(), outFile.readBytes())
}

@Test
fun applyPatch_badDiffHeader_returnsBadDiffHeader() {
// Well-formed length, wrong magic bytes (hand-written, not a real bsdiff output).
val oldFile = newFileFor("bad_header/old.dat").also { copyAssetToFile("bad_header/old.dat", it) }
val diffFile = newFileFor("bad_header/patch.bsdiff").also { copyAssetToFile("bad_header/patch.bsdiff", it) }
val outFile = File(tempFolder.root, "bad_header_out.dat")

val result = DiffPatch.applyPatch(oldFile.absolutePath, diffFile.absolutePath, outFile.absolutePath)

assertEquals(DiffPatch.PatchResult.BAD_DIFF_HEADER, result)
assertFalse("output file should not be left behind after a failed patch", outFile.exists())
}

@Test
fun applyPatch_mismatchedOldFile_returnsPatchFailed() {
// HDiffPatch's bounds checks must reject these inputs rather than reading out of range or
// silently emitting corrupt output. wrong_old/old.dat is unrelated to (and shorter than)
// basic/old.dat, so basic/patch.bsdiff's copy instructions reference offsets out of range for it.
val oldFile = newFileFor("wrong_old/old.dat").also { copyAssetToFile("wrong_old/old.dat", it) }
val diffFile = newFileFor("basic/patch.bsdiff").also { copyAssetToFile("basic/patch.bsdiff", it) }
val outFile = File(tempFolder.root, "mismatched_old_out.dat")

val result = DiffPatch.applyPatch(oldFile.absolutePath, diffFile.absolutePath, outFile.absolutePath)

assertEquals(DiffPatch.PatchResult.PATCH_FAILED, result)
assertFalse("output file should not be left behind after a failed patch", outFile.exists())
}

@Test
fun applyPatch_missingOldFile_returnsOpenOldFailed() {
val diffFile = newFileFor("basic/patch.bsdiff").also { copyAssetToFile("basic/patch.bsdiff", it) }
val missingOldFile = File(tempFolder.root, "does_not_exist_old.dat")
val outFile = File(tempFolder.root, "missing_old_out.dat")

val result = DiffPatch.applyPatch(missingOldFile.absolutePath, diffFile.absolutePath, outFile.absolutePath)

assertEquals(DiffPatch.PatchResult.OPEN_OLD_FAILED, result)
assertFalse("output file should not be left behind after a failed patch", outFile.exists())
}

@Test
fun applyPatch_missingDiffFile_returnsOpenDiffFailed() {
val oldFile = newFileFor("basic/old.dat").also { copyAssetToFile("basic/old.dat", it) }
val missingDiffFile = File(tempFolder.root, "does_not_exist.bsdiff")
val outFile = File(tempFolder.root, "missing_diff_out.dat")

val result = DiffPatch.applyPatch(oldFile.absolutePath, missingDiffFile.absolutePath, outFile.absolutePath)

assertEquals(DiffPatch.PatchResult.OPEN_DIFF_FAILED, result)
assertFalse("output file should not be left behind after a failed patch", outFile.exists())
}
}
55 changes: 55 additions & 0 deletions android/app/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
cmake_minimum_required(VERSION 3.22.1)
# C only for now, to keep libc++ and its bundle size out of the build.
# Vendored code is all in C, and our glue code is simple enough to be written in C,
# but this might need to be revisited in the future.
project(codepush_diffpatch C)

# Single shared library:
# - vendored HDiffPatch (bsdiff-compatible patch applier only)
# - vendored bzip2
# - our bridge from the HDiffPatch C API to the JNI layer
# - JNI glue
# See third_party/README.md for what we vendor and why.
add_library(codepush_diffpatch SHARED
diffpatch_jni.c
bspatch_bridge.c

third_party/hdiffpatch/libHDiffPatch/HPatch/patch.c
third_party/hdiffpatch/bsdiff_wrapper/bspatch_wrapper.c
third_party/hdiffpatch/file_for_patch.c

bzip2_error_stub.c
third_party/bzip2/bzlib.c
third_party/bzip2/decompress.c
third_party/bzip2/huffman.c
third_party/bzip2/crctable.c
third_party/bzip2/randtable.c
)

target_include_directories(codepush_diffpatch PRIVATE
.
third_party/bzip2
)

target_compile_definitions(codepush_diffpatch PRIVATE
# We only ever patch regular files under app-private storage, never
# raw block devices. Skip file_for_patch.c's block-device probing
# (avoids depending on <linux/fs.h> ioctls entirely).
_IS_NEED_BLOCK_DEV=0

# Defaults to 1, which pulls in hpatch_mt/*.h files.
# Not needed for our use case.
_IS_USED_MULTITHREAD=0

# Defaults 1, which compiles in hpatch_removeDir/hpatch_moveFile/hpatch_makeNewDir
# We only ever patch a single regular file.
_IS_NEED_DIR_DIFF_PATCH=0

# We only ever use bzip's decompression API.
# See third_party/README.md for details.
BZ_NO_COMPRESS=1
BZ_NO_STDIO=1
)

# HDiffPatch uses <android/log.h>
target_link_libraries(codepush_diffpatch log)
Loading