diff --git a/.github/workflows/npm_release.yml b/.github/workflows/npm_release.yml index 186b4b48b..a2bc5afa0 100644 --- a/.github/workflows/npm_release.yml +++ b/.github/workflows/npm_release.yml @@ -1,3 +1,4 @@ +name: NPM Release on: push: branches: @@ -19,7 +20,7 @@ permissions: jobs: build: name: Build - runs-on: macos-15-intel + runs-on: macos-15 outputs: npm_version: ${{ steps.npm_version_output.outputs.NPM_VERSION }} npm_tag: ${{ steps.npm_version_output.outputs.NPM_TAG }} @@ -90,7 +91,7 @@ jobs: with: name: debug-symbols path: test-app/runtime/build/intermediates/merged_native_libs/release/mergeReleaseNativeLibs/out/lib/* - + test: name: Test runs-on: macos-15-intel @@ -143,6 +144,12 @@ jobs: #target: google_apis arch: ${{env.ANDROID_ABI}} script: ./gradlew runtestsAndVerifyResults --stacktrace + - name: Upload Test Results + if: ${{ !cancelled() }} # run this step even if previous step failed + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: android-unit-test-results + path: test-app/dist/android_unit_test_results.xml publish: runs-on: ubuntu-latest environment: npm-publish diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index f6e7a9063..0a2569b7e 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -1,3 +1,4 @@ +name: Pull Request on: pull_request: @@ -9,14 +10,13 @@ env: ANDROID_ABI: x86_64 NDK_ARCH: darwin - permissions: contents: read jobs: build: name: Build - runs-on: macos-15-intel + runs-on: macos-15 outputs: npm_version: ${{ steps.npm_version_output.outputs.NPM_VERSION }} npm_tag: ${{ steps.npm_version_output.outputs.NPM_TAG }} @@ -128,4 +128,10 @@ jobs: # this is needed on API 30+ #target: google_apis arch: ${{env.ANDROID_ABI}} - script: ./gradlew runtestsAndVerifyResults --stacktrace \ No newline at end of file + script: ./gradlew runtestsAndVerifyResults --stacktrace + - name: Upload Test Results + if: ${{ !cancelled() }} # run this step even if previous step failed + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: android-unit-test-results + path: test-app/dist/android_unit_test_results.xml diff --git a/.github/workflows/test_report.yml b/.github/workflows/test_report.yml new file mode 100644 index 000000000..061bf3e23 --- /dev/null +++ b/.github/workflows/test_report.yml @@ -0,0 +1,20 @@ +name: "Test Report" +on: + workflow_run: + workflows: ["Pull Request", "NPM Release"] # runs after Pull Request workflow + types: + - completed +permissions: + contents: read + actions: read + checks: write +jobs: + report: + runs-on: ubuntu-latest + steps: + - uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0 + with: + name: Android Runtime Tests + artifact: android-unit-test-results # artifact name + path: test-app/dist/android_unit_test_results.xml + reporter: jest-junit # Format of test results diff --git a/test-app/app/src/main/assets/app/Infrastructure/Jasmine/jasmine-reporters/junit_reporter.js b/test-app/app/src/main/assets/app/Infrastructure/Jasmine/jasmine-reporters/junit_reporter.js index d7d1a6165..ccddaaf84 100644 --- a/test-app/app/src/main/assets/app/Infrastructure/Jasmine/jasmine-reporters/junit_reporter.js +++ b/test-app/app/src/main/assets/app/Infrastructure/Jasmine/jasmine-reporters/junit_reporter.js @@ -215,50 +215,7 @@ return; } catch (f) { errors.push(' NodeJS attempt: ' + f.message); } try { - // Instead of writing XML files, output test summary to console - // Parse the XML text to extract test summary - var testMatch = text.match(/tests="(\d+)"/g); - var failureMatch = text.match(/failures="(\d+)"/g); - var errorMatch = text.match(/errors="(\d+)"/g); - var skippedMatch = text.match(/skipped="(\d+)"/g); - - var totalTests = 0; - var totalFailures = 0; - var totalErrors = 0; - var totalSkipped = 0; - - // Sum up all test suite results - if (testMatch) { - for (var i = 0; i < testMatch.length; i++) { - var match = testMatch[i].match(/tests="(\d+)"/); - if (match) totalTests += parseInt(match[1]); - } - } - - if (failureMatch) { - for (var i = 0; i < failureMatch.length; i++) { - var match = failureMatch[i].match(/failures="(\d+)"/); - if (match) totalFailures += parseInt(match[1]); - } - } - - if (errorMatch) { - for (var i = 0; i < errorMatch.length; i++) { - var match = errorMatch[i].match(/errors="(\d+)"/); - if (match) totalErrors += parseInt(match[1]); - } - } - - if (skippedMatch) { - for (var i = 0; i < skippedMatch.length; i++) { - var match = skippedMatch[i].match(/skipped="(\d+)"/); - if (match) totalSkipped += parseInt(match[1]); - } - } - - // Output in a format our test checker can detect - var resultPrefix = (totalFailures > 0 || totalErrors > 0) ? "FAILURE:" : "SUCCESS:"; - console.log(resultPrefix + " " + totalTests + " specs, " + (totalFailures + totalErrors) + " failures, " + totalSkipped + " skipped"); + __JUnitSaveResults(text); return; } catch (f) { diff --git a/test-app/app/src/main/assets/app/boot.js b/test-app/app/src/main/assets/app/boot.js index bc2ec7e14..91659d777 100644 --- a/test-app/app/src/main/assets/app/boot.js +++ b/test-app/app/src/main/assets/app/boot.js @@ -14,6 +14,21 @@ global.__onUncaughtError = function(error){ } require('./Infrastructure/timers'); +global.__JUnitSaveResults = function (unitTestResults) { + var pathToApp = '/data/data/com.tns.testapplication'; + var unitTestFileName = 'android_unit_test_results.xml'; + try { + var javaFile = new java.io.File(pathToApp, unitTestFileName); + var stream = new java.io.FileOutputStream(javaFile); + var actualEncoding = 'UTF-8'; + var writer = new java.io.OutputStreamWriter(stream, actualEncoding); + writer.write(unitTestResults); + writer.close(); + } + catch (exception) { + android.util.Log.d("TEST RESULTS", 'failed writing to files dir: ' + exception) + } +}; require('./Infrastructure/Jasmine/jasmine-2.0.1/boot'); //runs jasmine, attaches the junitOutputter diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js index b3d599c43..c3fd1f3d8 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -73,6 +73,4 @@ require('./tests/testURLSearchParamsImpl.js'); require('./tests/testPerformanceNow'); require('./tests/testQueueMicrotask'); -// ES MODULE TESTS -__log("=== Running ES Modules Tests ==="); require("./tests/testESModules.mjs"); diff --git a/test-app/app/src/main/assets/app/tests/testESModules.mjs b/test-app/app/src/main/assets/app/tests/testESModules.mjs index 64bea7e90..02aaf7627 100644 --- a/test-app/app/src/main/assets/app/tests/testESModules.mjs +++ b/test-app/app/src/main/assets/app/tests/testESModules.mjs @@ -1,167 +1,34 @@ -async function runESModuleTests() { - let passed = 0; - let failed = 0; - const failureDetails = []; - - const recordPass = (message, ...args) => { - console.log(`✅ PASS: ${message}`, ...args); - passed++; - }; - - const recordFailure = (message, options = {}) => { - const { error, details = [] } = options; - const fullMessage = error?.message - ? `${message}: ${error.message}` - : message; - console.log(`❌ FAIL: ${fullMessage}`); - details.forEach((detail) => console.log(detail)); - if (error?.stack) { - console.log("Stack trace:", error.stack); - } - failed++; - failureDetails.push(fullMessage); - }; - - const logFinalSummary = () => { - console.log("\n=== ES MODULE TEST RESULTS ==="); - console.log("Tests passed:", passed); - console.log("Tests failed:", failed); - console.log("Total tests:", passed + failed); - - if (failed === 0) { - console.log("ALL ES MODULE TESTS PASSED!"); - } else { - console.log("SOME ES MODULE TESTS FAILED!"); - console.log("FAILURE DETECTED: Starting failure logging"); - failureDetails.forEach((detail) => { - console.log(` ❌ ${detail}`); - }); - } - }; - - try { - // Test 1: Load .mjs files as ES modules - console.log("\n--- Test 1: Loading .mjs files as ES modules ---"); - try { - const moduleExports = await import("~/testSimpleESModule.mjs"); - if (moduleExports) { - recordPass("Module exports:", JSON.stringify(moduleExports)); - } else { - recordFailure("ES Module loaded but exports are null"); - } - - if (moduleExports?.moduleType === "ES Module") { - recordPass("moduleType check passed"); - } else { - recordFailure("moduleType check failed"); - } - } catch (e) { - recordFailure("Error loading ES module", { error: e }); - } - - // Test 2: Test import.meta functionality - console.log("\n--- Test 2: Testing import.meta functionality ---"); - try { - const importMetaModule = await import("~/testImportMeta.mjs"); - if ( - importMetaModule && - importMetaModule.default && - typeof importMetaModule.default === "function" - ) { - const metaResults = importMetaModule.default(); - console.log( - "import.meta test results:", - JSON.stringify(metaResults, null, 2) - ); - - if ( - metaResults && - metaResults.hasImportMeta && - metaResults.hasUrl && - metaResults.hasDirname - ) { - recordPass("import.meta properties present"); - console.log(" - import.meta.url:", metaResults.url); - console.log(" - import.meta.dirname:", metaResults.dirname); - } else { - recordFailure("import.meta properties missing", { - details: [ - ` - hasImportMeta: ${metaResults?.hasImportMeta}`, - ` - hasUrl: ${metaResults?.hasUrl}`, - ` - hasDirname: ${metaResults?.hasDirname}`, - ], - }); - } - } else { - recordFailure("import.meta module has no default export function"); - } - } catch (e) { - recordFailure("Error testing import.meta", { error: e }); - } - - // Test 3: Test Worker enhancements - console.log("\n--- Test 3: Testing Worker enhancements ---"); - try { - const workerModule = await import("~/testWorkerFeatures.mjs"); - if ( - workerModule && - workerModule.testWorkerFeatures && - typeof workerModule.testWorkerFeatures === "function" - ) { - const workerResults = workerModule.testWorkerFeatures(); - console.log( - "Worker features test results:", - JSON.stringify(workerResults, null, 2) - ); - - if ( - workerResults && - workerResults.stringPathSupported && - workerResults.urlObjectSupported && - workerResults.tildePathSupported - ) { - recordPass("Worker enhancement features present"); - console.log( - " - String path support:", - workerResults.stringPathSupported - ); - console.log( - " - URL object support:", - workerResults.urlObjectSupported - ); - console.log( - " - Tilde path support:", - workerResults.tildePathSupported - ); - } else { - recordFailure("Worker enhancement features missing", { - details: [ - ` - stringPathSupported: ${workerResults?.stringPathSupported}`, - ` - urlObjectSupported: ${workerResults?.urlObjectSupported}`, - ` - tildePathSupported: ${workerResults?.tildePathSupported}`, - ], - }); - } - } else { - recordFailure( - "Worker features module has no testWorkerFeatures function" - ); - } - } catch (e) { - recordFailure("Error testing Worker features", { error: e }); - } - } catch (unexpectedError) { - recordFailure("Unexpected ES module test harness failure", { - error: unexpectedError, - }); - } finally { - logFinalSummary(); - } - - return { passed, failed }; -} - -// Run the tests immediately (avoid top-level await for broader runtime support) -runESModuleTests().catch((e) => { - console.error("ES Module top-level failure:", e?.message ?? e); +describe("ES Modules", () => { + it("loads .mjs files as ES modules", async () => { + const moduleExports = await import("~/testSimpleESModule.mjs"); + expect(moduleExports).toBeTruthy(); + expect(moduleExports?.moduleType).toBe("ES Module"); + }); + + it("supports import.meta functionality", async () => { + const importMetaModule = await import("~/testImportMeta.mjs"); + expect(importMetaModule).toBeTruthy(); + expect(typeof importMetaModule.default).toBe("function"); + + const metaResults = importMetaModule.default(); + expect(metaResults).toBeTruthy(); + expect(metaResults.hasImportMeta).toBe(true); + expect(metaResults.hasUrl).toBe(true); + expect(metaResults.hasDirname).toBe(true); + expect(metaResults.url).toBeTruthy(); + expect(metaResults.dirname).toBeTruthy(); + }); + + it("supports Worker enhancements", async () => { + // TODO: make these tests actually be normal tests instead of just importing and checking existence + const workerModule = await import("~/testWorkerFeatures.mjs"); + expect(workerModule).toBeTruthy(); + expect(typeof workerModule.testWorkerFeatures).toBe("function"); + + const workerResults = workerModule.testWorkerFeatures(); + expect(workerResults).toBeTruthy(); + expect(workerResults.stringPathSupported).toBe(true); + expect(workerResults.urlObjectSupported).toBe(true); + expect(workerResults.tildePathSupported).toBe(true); + }); }); diff --git a/test-app/runtests.gradle b/test-app/runtests.gradle index 356059ff3..6af55d065 100644 --- a/test-app/runtests.gradle +++ b/test-app/runtests.gradle @@ -68,9 +68,9 @@ task deletePreviousResultXml(type: Exec) { println "Removing previous android_unit_test_results.xml" if (isWinOs) { - commandLine "cmd", "/c", "adb", runOnDeviceOrEmulator, "shell", "run-as", "com.tns.testapplication", "rm", "-f", "/data/data/com.tns.testapplication/android_unit_test_results.xml", "||", "true" + commandLine "cmd", "/c", "adb", runOnDeviceOrEmulator, "-e", "shell", "rm", "-rf", "/data/data/com.tns.testapplication/android_unit_test_results.xml" } else { - commandLine "bash", "-c", "adb " + runOnDeviceOrEmulator + " shell 'run-as com.tns.testapplication rm -f /data/data/com.tns.testapplication/android_unit_test_results.xml || true'" + commandLine "adb", runOnDeviceOrEmulator, "-e", "shell", "rm", "-rf", "/data/data/com.tns.testapplication/android_unit_test_results.xml" } } } @@ -80,9 +80,9 @@ task startInstalledApk(type: Exec) { println "Starting test application" if (isWinOs) { - commandLine "cmd", "/c", "adb", runOnDeviceOrEmulator, "shell", "am", "start", "-n", "com.tns.testapplication/com.tns.NativeScriptActivity", "-a", "android.intent.action.MAIN", "-c", "android.intent.category.LAUNCHER" + commandLine "cmd", "/c", "adb", runOnDeviceOrEmulator, "-e", "shell", "am", "start", "-n", "com.tns.testapplication/com.tns.NativeScriptActivity", "-a", "android.intent.action.MAIN", "-c", "android.intent.category.LAUNCHER" } else { - commandLine "adb", runOnDeviceOrEmulator, "shell", "am", "start", "-n", "com.tns.testapplication/com.tns.NativeScriptActivity", "-a", "android.intent.action.MAIN", "-c", "android.intent.category.LAUNCHER" + commandLine "adb", runOnDeviceOrEmulator, "-e", "shell", "am", "start", "-n", "com.tns.testapplication/com.tns.NativeScriptActivity", "-a", "android.intent.action.MAIN", "-c", "android.intent.category.LAUNCHER" } } } @@ -94,27 +94,39 @@ task createDistFolder { } } -task waitForTestsToComplete { - doLast { - println "Waiting for tests to complete..." - Thread.sleep(15000) // Wait 15 seconds for tests to run +task waitForUnitTestResultFile(type: Exec) { + doFirst { + println "Waiting for tests to finish..." + + if (isWinOs) { + commandLine "cmd", "/c", "node", "$rootDir\\tools\\try_to_find_test_result_file.js", runOnDeviceOrEmulator + } else { + commandLine "node", "$rootDir/tools/try_to_find_test_result_file.js", runOnDeviceOrEmulator + } } } +task copyResultToDist(type: Copy) { + from "$rootDir/android_unit_test_results.xml" + into "$rootDir/dist" +} + +task deleteRootLevelResult(type: Delete) { + delete "$rootDir/android_unit_test_results.xml" +} + task verifyResults(type: Exec) { doFirst { - println "Verifying test results from console output..." - if (isWinOs) { - commandLine "cmd", "/c", "node", "$rootDir\\tools\\check_console_test_results.js" + commandLine "cmd", "/c", "node", "$rootDir\\tools\\check_if_tests_passed.js", "$rootDir\\dist\\android_unit_test_results.xml" } else { - commandLine "node", "$rootDir/tools/check_console_test_results.js" + commandLine "node", "$rootDir/tools/check_if_tests_passed.js", "$rootDir/dist/android_unit_test_results.xml" } } } task runtests { - dependsOn startInstalledApk + dependsOn deleteRootLevelResult } // waitForEmulatorToStart.dependsOn(deleteDist) @@ -123,8 +135,10 @@ deletePreviousResultXml.dependsOn(runAdbAsRoot) installApk.dependsOn(deletePreviousResultXml) startInstalledApk.dependsOn(installApk) createDistFolder.dependsOn(startInstalledApk) -waitForTestsToComplete.dependsOn(createDistFolder) -verifyResults.dependsOn(waitForTestsToComplete) +waitForUnitTestResultFile.dependsOn(createDistFolder) +copyResultToDist.dependsOn(waitForUnitTestResultFile) +deleteRootLevelResult.dependsOn(copyResultToDist) +verifyResults.dependsOn(runtests) task runtestsAndVerifyResults { dependsOn verifyResults diff --git a/test-app/tools/check_if_tests_passed.js b/test-app/tools/check_if_tests_passed.js index 6e62e2868..d90a46f8d 100644 --- a/test-app/tools/check_if_tests_passed.js +++ b/test-app/tools/check_if_tests_passed.js @@ -1,58 +1,138 @@ -const - fs = require("fs"), - xml2js = require("xml2js"), - parser = new xml2js.Parser(); +const fs = require("fs"), + xml2js = require("xml2js"), + parser = new xml2js.Parser(); + +// TODO: check why gradle doesn't show error logs +console.error = console.log; if (process.argv.length < 3) { - console.error(`Usage: node ${process.argv[1]} `); - process.exit(1); + console.error(`Usage: node ${process.argv[1]} `); + process.exit(1); } const testResultsFile = process.argv[2]; if (!fs.existsSync(testResultsFile)) { - console.error(`The specified file ${testResultsFile} does not exist`); - process.exit(1); + console.error(`The specified file ${testResultsFile} does not exist`); + process.exit(1); } console.log(`\nStart processing ${testResultsFile}\n`); -fs.readFile(testResultsFile, function(err, data) { - if (err) { - console.error(`An error occurred while reading ${testResultsFile}: ${err}`); - process.exit(1); - } - - parser.parseString(data, function (err, result) { - if (err) { - console.error(`Unable to parse ${testResultsFile}: ${err}`); - process.exit(1); - } - - if (!result.testsuites) { - console.error(`Invalid XML: expected element.`); - process.exit(1); - } - - const testSuites = result.testsuites.testsuite || []; - const failedTests = []; - for (var i = 0; i < testSuites.length; i++) { - const testSuite = testSuites[i]; - const testCases = testSuite.testcase || []; - for (var j = 0; j < testCases.length; j++) { - const testCase = testCases[j]; - if (testCase.failure) { - const failures = testCase.failure || []; - for (var k = 0; k < failures.length; k++) { - const failure = failures[k]; - failedTests.push(`${testCase.$.name}\n${failure._}`); - } - } - } - } - - if (failedTests.length > 0) { - console.error(`\nFailed test cases:\n${failedTests.join("\n")}\n`); - process.exit(1); - } - }); -}); \ No newline at end of file +fs.readFile(testResultsFile, function (err, data) { + if (err) { + console.error(`An error occurred while reading ${testResultsFile}: ${err}`); + process.exit(1); + } + + parser.parseString(data, function (err, result) { + if (err) { + console.error(`Unable to parse ${testResultsFile}: ${err}`); + process.exit(1); + } + + if (!result.testsuites) { + console.error(`Invalid XML: expected element.`); + process.exit(1); + } + + const testSuites = result.testsuites.testsuite || []; + const failedTests = []; + let totalTests = 0; + let passedTests = 0; + let failedTestCount = 0; + + console.log(`Found ${testSuites.length} test suite(s)\n`); + + for (var i = 0; i < testSuites.length; i++) { + const testSuite = testSuites[i]; + const testCases = testSuite.testcase || []; + const suiteName = testSuite.$.name || `Suite ${i + 1}`; + + console.log(`Processing test suite: ${suiteName}`); + console.log(` Tests in suite: ${testCases.length}`); + + for (var j = 0; j < testCases.length; j++) { + const testCase = testCases[j]; + const testName = testCase.$.name; + totalTests++; + + if (testCase.failure) { + failedTestCount++; + console.log(` ✗ FAILED: ${testName}`); + const failures = testCase.failure || []; + for (var k = 0; k < failures.length; k++) { + const failure = failures[k]; + failedTests.push({ + suite: suiteName, + test: testName, + message: failure.$ ? failure.$.message : "", + details: failure._, + }); + } + if (failures.length === 0) { + failedTests.push({ + suite: suiteName, + test: testName, + message: "Unknown failure", + details: "No failure details provided in the test results.", + }); + } + } else { + passedTests++; + console.log(` ✓ PASSED: ${testName}`); + } + } + console.log(""); + } + + console.log(`\n${"=".repeat(60)}`); + console.log(`Test Results Summary:`); + console.log(` Total Tests: ${totalTests}`); + console.log(` Passed: ${passedTests}`); + console.log(` Failed: ${failedTestCount}`); + console.log(`${"=".repeat(60)}\n`); + + if (failedTests.length > 0) { + console.error(`\n${"!".repeat(60)}`); + console.error(`FAILED TEST DETAILS (${failedTests.length} failure(s)):`); + console.error(`${"!".repeat(60)}\n`); + + // Group failures by suite + const failuresBySuite = {}; + for (var i = 0; i < failedTests.length; i++) { + const failure = failedTests[i]; + if (!failuresBySuite[failure.suite]) { + failuresBySuite[failure.suite] = []; + } + failuresBySuite[failure.suite].push(failure); + } + + // Display grouped failures + let failureCount = 1; + for (const suite in failuresBySuite) { + console.error(`Test Suite: ${suite}`); + const suiteFailures = failuresBySuite[suite]; + for (var i = 0; i < suiteFailures.length; i++) { + const failure = suiteFailures[i]; + console.error(` [${failureCount}] Test Name: ${failure.test}`); + if (failure.message) { + console.error(` Error Type: ${failure.message}`); + } + console.error(` Details:`); + const details = failure.details.split("\n"); + for (var j = 0; j < details.length; j++) { + if (details[j].trim()) { + console.error(` ${details[j]}`); + } + } + console.error(""); + failureCount++; + } + } + console.error(`${"!".repeat(60)}\n`); + process.exit(1); + } else { + console.log("All tests passed! ✓\n"); + } + }); +}); diff --git a/test-app/tools/try_to_find_test_result_file.js b/test-app/tools/try_to_find_test_result_file.js index bac335c75..6dd6f4d69 100644 --- a/test-app/tools/try_to_find_test_result_file.js +++ b/test-app/tools/try_to_find_test_result_file.js @@ -1,60 +1,150 @@ -var - searchForFile = require('child_process').exec, - execFindFile = require('child_process').exec, - checkIfAppIsRunning = require('child_process').exec, - fs = require('fs'), - pullfile, +const { exec } = require("child_process"); - isTimeToExit = false, +const appId = "com.tns.testapplication"; +const runOnDeviceOrEmulator = process.argv[2] || ""; +const adbPrefix = `adb ${runOnDeviceOrEmulator} -e`; +const resultsPath = `/data/data/${appId}/android_unit_test_results.xml`; - processTimeout = 20 * 60 * 1000, // 20 minutes timeout (empirical constant :)) - searchInterval = 10 * 1000; +const processTimeoutMs = 20 * 60 * 1000; // 20 minutes timeout (empirical constant) +const pollIntervalMs = 10 * 1000; +const scriptStartTime = new Date(); +const logcatCutoffTime = new Date(scriptStartTime.getTime() - 60 * 1000); // 1 minute before start -searchForFile("empty", getFile); +let timedOut = false; +// TODO: check why gradle doesn't show error logs +console.error = console.log; // Redirect stderr to stdout for easier logcat capture -var runOnDeviceOrEmulator = process.argv[2]; +function execAndStream(command, streamOutput = false) { + return new Promise((resolve) => { + const child = exec(command, (error, stdout, stderr) => { + resolve({ error, stdout, stderr }); + }); -function getFile(error, stdout, stderr) { - closeProcessAfter(processTimeout); - setInterval(tryToGetFile, searchInterval); -}; + if (!streamOutput) { + return; + } + if (child.stdout) { + child.stdout.pipe(process.stdout, { end: false }); + } + if (child.stderr) { + child.stderr.pipe(process.stderr, { end: false }); + } + }); +} + +function parseLogcatTimestamp(line) { + // Logcat format: "MM-DD HH:mm:ss.mmm" + const timeMatch = line.match(/(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\.(\d{3})/); + if (!timeMatch) return null; + + const month = parseInt(timeMatch[1], 10) - 1; // JavaScript months are 0-indexed + const day = parseInt(timeMatch[2], 10); + const hour = parseInt(timeMatch[3], 10); + const minute = parseInt(timeMatch[4], 10); + const second = parseInt(timeMatch[5], 10); + const ms = parseInt(timeMatch[6], 10); + + // Create a date object for this log entry (use current year) + const logDate = new Date(); + logDate.setMonth(month); + logDate.setDate(day); + logDate.setHours(hour, minute, second, ms); + + return logDate; +} + +function filterLogcatFromCutoff(logcatOutput) { + const lines = logcatOutput.split("\n"); + + // Find the first line that is at or after the cutoff time + const startIndex = lines.findIndex((line) => { + const timestamp = parseLogcatTimestamp(line); + return timestamp && timestamp >= logcatCutoffTime; + }); + + // If no matching line found, return empty; otherwise return from that line onward + if (startIndex === -1) return ""; + return lines.slice(startIndex).join("\n"); +} + +async function readAndFilterLogcat() { + return new Promise((resolve) => { + exec(`${adbPrefix} logcat -d | grep ${appId}`, (error, stdout) => { + resolve(filterLogcatFromCutoff(stdout || "")); + }); + }); +} -function closeProcessAfter(timeout) { - //this will force process closed even if the the file is not found - setTimeout(function () { isTimeToExit = true; }, timeout); +async function exitWithLogcatDump() { + console.log("Dumping logcat for debugging:"); + try { + const logcat = await readAndFilterLogcat(); + console.log(logcat); + } finally { + process.exit(1); + } } -function tryToGetFile() { - var checkApp = checkIfAppIsRunning("adb " + runOnDeviceOrEmulator + " -e shell \"ps | grep com.tns.testapplication\"", checkIfProcessIsRunning); - pullfile = execFindFile("adb " + runOnDeviceOrEmulator + " -e pull /data/data/com.tns.testapplication/android_unit_test_results.xml", checkIfFileExists); - pullfile.stdout.pipe(process.stdout, { end: false }); - pullfile.stderr.pipe(process.stderr, { end: false }); +async function ensureProcessAlive() { + const { stdout } = await execAndStream( + `${adbPrefix} shell "ps | grep ${appId}"` + ); + + if (!stdout) { + console.error(`${appId} process died or never started!`); + await exitWithLogcatDump(); + } + + console.log(`${appId} process is running`); } -function checkIfProcessIsRunning(err, stdout, stderr) { - if(stdout) { - console.log("com.tns.testapplication process is running") - } - else { - console.log('com.tns.testapplication process died or never started!'); - process.exit(1); - } +async function checkForErrorActivity() { + const { error } = await execAndStream( + `${adbPrefix} shell "dumpsys activity activities | grep ${appId} | grep ${appId}.ErrorReportActivity"` + ); + if (!error) { + console.error("App has crashed - ErrorReportActivity is displaying!"); + await exitWithLogcatDump(); + } } -function checkIfFileExists(err, stout, stderr) { +async function tryPullResultsFile() { + const { error } = await execAndStream(`${adbPrefix} pull ${resultsPath}`); - //if you find file in /data/data/com.tns.testapplication exit process - if (!err) { - console.log('Tests results file found file!'); - process.exit(); - } - else { - //if the time to get the file is out exit process - if (isTimeToExit) { - console.log(err); - console.log('Tests results file not found!'); - process.exit(1); - } - } + if (!error) { + console.log("Tests results file found!"); + process.exit(0); + } } + +async function pollForResults() { + await ensureProcessAlive(); + await checkForErrorActivity(); + await tryPullResultsFile(); + + if (timedOut) { + console.error("Tests results file not found within timeout window."); + await exitWithLogcatDump(); + } + + setTimeout(pollForResults, pollIntervalMs); +} + +function main() { + setTimeout(() => { + timedOut = true; + }, processTimeoutMs); + + console.log( + `Waiting for test results file on ${ + runOnDeviceOrEmulator || "default device" + }...` + ); + pollForResults().catch((err) => { + console.error("Unexpected failure while waiting for results", err); + process.exit(1); + }); +} + +main();