diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index bebe187487..871b138259 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -25,3 +25,70 @@ jobs: - name: Build all TS apps and widgets working-directory: ./typescript run: npm run build + + functional-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository and submodules + uses: actions/checkout@v3 + with: + submodules: recursive + fetch-depth: 0 + + - name: Use Node.js 18.x + uses: actions/setup-node@v3 + with: + node-version: 18.x + + - name: Clone EspruinoWebIDE (pinned) + run: | + git clone --depth 1 https://github.com/espruino/EspruinoWebIDE ../EspruinoWebIDE + cd ../EspruinoWebIDE + git fetch --depth 1 origin a62beceec317f6744e58aaa2d1a19447d9458035 + git checkout a62beceec317f6744e58aaa2d1a19447d9458035 + + - name: Detect changed apps + id: detect-apps + run: | + set -e # Exit on error, but handle grep specially + if [ "${{ github.event_name }}" = "pull_request" ]; then + # PR: test only changed apps + CHANGED=$(git diff --name-only ${{ github.event.pull_request.base.sha }}..HEAD) + APPS=$(echo "$CHANGED" | grep '^apps/' | cut -d'/' -f2 | sort -u) || APPS="" + elif [ "${{ github.ref }}" = "refs/heads/master" ]; then + # Push to master: test all apps + APPS=$(ls apps/*/test.json 2>/dev/null | cut -d'/' -f2) || APPS="" + else + # Push to other branches: test only changed apps since master + CHANGED=$(git diff --name-only origin/master...HEAD) + APPS=$(echo "$CHANGED" | grep '^apps/' | cut -d'/' -f2 | sort -u) || APPS="" + fi + # Filter to apps with test.json + TESTABLE="" + for app in $APPS; do + if [ -f "apps/$app/test.json" ]; then + TESTABLE="$TESTABLE $app" + fi + done + echo "apps=$TESTABLE" >> $GITHUB_OUTPUT + echo "Detected testable apps:$TESTABLE" + + - name: Run app tests + run: | + APPS="${{ steps.detect-apps.outputs.apps }}" + if [ -z "$APPS" ]; then + echo "No apps with tests to run" + exit 0 + fi + FAILED=0 + for app in $APPS; do + echo "" + echo "=========================================" + echo "Testing $app..." + echo "=========================================" + if ! node bin/runapptests.js --id "$app" --verbose; then + FAILED=1 + fi + done + exit $FAILED diff --git a/README.md b/README.md index 1cfd9f3ab3..930f74d262 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ You can also take multiple screenshots and use a website like [this](https://ezg ## Testing +For functional testing of apps using the Bangle.js emulator, see [TESTING.md](TESTING.md). + ### Online This is the best way to test... diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000000..2189d8f949 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,116 @@ +# BangleApps Testing Documentation + +This document describes how to write and run functional tests for Bangle.js apps using the emulator-based test runner. + +## Running Tests + +### Prerequisites + +Clone the EspruinoWebIDE at the same level as BangleApps: + +```bash +cd .. +git clone https://github.com/espruino/EspruinoWebIDE +``` + +### Running All Tests + +```bash +node bin/runapptests.js --verbose +``` + +### Running Tests for a Specific App + +```bash +node bin/runapptests.js --id android --verbose +``` + +## Writing Tests + +Create a `test.json` file in your app's directory (e.g., `apps/myapp/test.json`). + +### Basic Structure + +```json +{ + "app": "myapp", + "setup": [ + { + "id": "default", + "steps": [ + {"t": "cmd", "js": "/* setup code */"} + ] + } + ], + "tests": [ + { + "description": "Test description", + "steps": [ + {"t": "setup", "id": "default"}, + {"t": "assert", "js": "true", "is": "truthy"} + ] + } + ] +} +``` + +For available step types and assert conditions, see the inline documentation in `bin/runapptests.js`. + +### Example: Testing GPS Power Management + +```json +{ + "app": "android", + "setup": [{ + "id": "default", + "steps": [ + {"t": "cmd", "js": "Bangle.setGPSPower = (on, id) => { /* mock */ }"}, + {"t": "wrap", "fn": "Bangle.setGPSPower", "id": "gpspower"}, + {"t": "cmd", "js": "eval(require('Storage').read('android.boot.js'))"} + ] + }], + "tests": [{ + "description": "Check GPS power is managed correctly", + "steps": [ + {"t": "setup", "id": "default"}, + {"t": "assert", "js": "Bangle.setGPSPower(1, 'test')", "is": "truthy"}, + {"t": "assertCall", "id": "gpspower", "count": 1} + ] + }] +} +``` + +## CI Behavior + +Tests run automatically in GitHub Actions: + +- **On Pull Requests**: Only apps with changed files AND a `test.json` are tested +- **On Push to master**: All apps with `test.json` are tested + +### Test Results + +- **SUCCESS**: All assertions passed +- **FAILURE**: One or more assertions failed +- **TIMEOUT**: Test took longer than 60 seconds (possible infinite loop) + +## Troubleshooting + +### "You need to git clone EspruinoWebIDE" + +The emulator dependency is missing. Run: + +```bash +cd .. +git clone https://github.com/espruino/EspruinoWebIDE +``` + +### Test hangs indefinitely + +Tests now have a 60-second timeout. If your test needs longer: +1. Check for infinite loops in your code +2. Check that promises are resolving +3. Consider breaking into smaller tests + +### Memory tests are flaky + +Memory comparison tests (`saveMemoryUsage`/`checkMemoryUsage`) can be sensitive to GC timing. Consider using tolerance or avoiding exact memory comparisons. diff --git a/apps/agenda/ChangeLog b/apps/agenda/ChangeLog index 4d2e280968..5c0a9ba596 100644 --- a/apps/agenda/ChangeLog +++ b/apps/agenda/ChangeLog @@ -19,3 +19,4 @@ 0.17: Fixed "Today" and "Tomorrow" labels displaying in non-current weeks 0.18: Correct date in clockinfo for all-day events in negative timezones 0.19: Change clockinfo title truncation to preserve images +0.20: Add back button support to "No events" screen diff --git a/apps/agenda/agenda.js b/apps/agenda/agenda.js index f8fffc643d..53a120543f 100644 --- a/apps/agenda/agenda.js +++ b/apps/agenda/agenda.js @@ -135,6 +135,7 @@ function showList() { } if(CALENDAR.length == 0) { E.showMessage(/*LANG*/"No events"); + Bangle.setUI({mode: "custom", back: load}); return; } E.showScroller({ diff --git a/apps/agenda/metadata.json b/apps/agenda/metadata.json index bfa529ea58..396b3bb92a 100644 --- a/apps/agenda/metadata.json +++ b/apps/agenda/metadata.json @@ -1,7 +1,7 @@ { "id": "agenda", "name": "Agenda", - "version": "0.19", + "version": "0.20", "description": "Simple agenda", "icon": "agenda.png", "screenshots": [{"url":"screenshot_agenda_overview.png"}, {"url":"screenshot_agenda_event1.png"}, {"url":"screenshot_agenda_event2.png"}], diff --git a/apps/messagesoverlay/ChangeLog b/apps/messagesoverlay/ChangeLog index 094694c0d7..f32a842de9 100644 --- a/apps/messagesoverlay/ChangeLog +++ b/apps/messagesoverlay/ChangeLog @@ -14,4 +14,5 @@ 0.09: Fix scrolling to last line for long text 0.10: Track Listeners added with prependListener Handle changed internal callback variable name for watches introduced in 2v21.104 -0.11: Update for new setLCDOverlay remove handler \ No newline at end of file +0.11: Update for new setLCDOverlay remove handler +0.12: Fix premature handler restoration when overlay is updated during message display diff --git a/apps/messagesoverlay/lib.js b/apps/messagesoverlay/lib.js index 9119999bd6..a3eab17345 100644 --- a/apps/messagesoverlay/lib.js +++ b/apps/messagesoverlay/lib.js @@ -48,6 +48,7 @@ const isQuiet = function(){ let eventQueue = []; let callInProgress = false; let buzzing = false; +let overlayShowing = false; const show = function(){ let img = ovr.asImage(); @@ -55,7 +56,18 @@ const show = function(){ if (ovr.getBPP() == 1) { img.palette = new Uint16Array([g.theme.fg,g.theme.bg]); } - Bangle.setLCDOverlay(img, ovrx, ovry, {id:"messagesoverlay", remove:cleanup}); + // Only register remove callback on first show to avoid premature cleanup + // when setLCDOverlay replaces existing overlay with same ID + const opts = {id:"messagesoverlay"}; + const isFirstShow = !overlayShowing; + if (isFirstShow) { + opts.remove = cleanup; + } + Bangle.setLCDOverlay(img, ovrx, ovry, opts); + // Set flag after successful call to prevent state corruption if setLCDOverlay throws + if (isFirstShow) { + overlayShowing = true; + } }; const manageEvent = function(event) { @@ -620,6 +632,7 @@ const cleanup = function(){ Bangle.setLCDOverlay(undefined, {id: "messagesoverlay"}); ovr = undefined; + overlayShowing = false; }; const backup = {}; diff --git a/apps/messagesoverlay/metadata.json b/apps/messagesoverlay/metadata.json index ba5f005c3c..34aaa93a97 100644 --- a/apps/messagesoverlay/metadata.json +++ b/apps/messagesoverlay/metadata.json @@ -1,7 +1,7 @@ { "id": "messagesoverlay", "name": "Messages Overlay", - "version": "0.11", + "version": "0.12", "description": "An overlay based implementation of a messages UI (display notifications from iOS and Gadgetbridge/Android)", "icon": "app.png", "type": "bootloader", diff --git a/apps/messagesoverlay/test.json b/apps/messagesoverlay/test.json index 77e12caa66..0917c4d1ad 100644 --- a/apps/messagesoverlay/test.json +++ b/apps/messagesoverlay/test.json @@ -1,22 +1,22 @@ { "app" : "messagesoverlay", "tests" : [{ - "description": "Test handler backgrounding", + "description": "Test handler backgrounding", "steps" : [ {"t":"upload", "file": "modules/widget_utils.js", "as": "widget_utils"}, {"t":"cmd", "js": "Bangle.loadWidgets()", "text": "Load widgets"}, {"t":"assertArray", "js": "Bangle['#onswipe']", "is":"undefinedOrEmpty", "text": "No swipe handlers"}, {"t":"cmd", "js": "require('widget_utils').swipeOn(0)", "text": "Store widgets in overlay"}, - {"t":"assert", "js": "Bangle['#onswipe']", "is":"function", "text": "One swipe handler for widgets"}, + {"t":"assert", "js": "require('widget_utils').swipeHandler", "is":"function", "text": "Swipe handler registered on widget_utils"}, {"t":"emit", "event":"swipe", "paramsArray": [ 0, 1 ], "text": "Show widgets"}, - {"t":"assert", "js": "Bangle['#onswipe']", "is":"function", "text": "One swipe handler for widgets"}, + {"t":"assert", "js": "require('widget_utils').swipeHandler", "is":"function", "text": "Swipe handler still registered"}, {"t":"cmd", "js": "require('messagesoverlay').message('text', {src:'Messenger',t:'add',type:'text',id:Date.now().toFixed(0),title:'title',body:'body'})", "text": "Show a message overlay"}, {"t":"assertArray", "js": "Bangle['#onswipe']", "is":"undefinedOrEmpty", "text": "No swipe handlers while message overlay is on screen"}, {"t":"emit", "event":"touch", "paramsArray": [ 1, { "x": 10, "y": 10, "type": 0 } ], "text": "Close message"}, - {"t":"assert", "js": "Bangle['#onswipe']", "is":"function", "text": "One swipe handler restored"} + {"t":"assert", "js": "require('widget_utils').swipeHandler", "is":"function", "text": "Swipe handler restored on widget_utils"} ] },{ - "description": "Test swipe handler backgrounding with fastloading (setUI)", + "description": "Test swipe handler backgrounding with fastloading (setUI)", "steps" : [ {"t":"cmd", "js": "Bangle.on('swipe',print)", "text": "Create listener for swipes"}, {"t":"cmd", "js": "Bangle.setUI({mode: 'clock',remove: ()=>{}})", "text": "Init UI for clock"}, @@ -25,10 +25,24 @@ {"t":"cmd", "js": "Bangle.setUI()", "text": "Trigger removal of UI"}, {"t":"assertArray", "js": "Bangle['#onswipe']", "is":"undefinedOrEmpty", "text": "Still no swipe handlers"}, {"t":"emit", "event":"touch", "paramsArray": [ 1, { "x": 10, "y": 10, "type": 0 } ], "text": "Close message"}, - {"t":"assert", "js": "Bangle['#onswipe']", "is":"function", "text": "One swipe handler restored"} + {"t":"assert", "js": "Bangle['#onswipe']", "is":"truthy", "text": "Swipe handler restored"} ] },{ - "description": "Test watch backgrounding", + "description": "Test overlay update does not trigger premature cleanup", + "steps" : [ + {"t":"cmd", "js": "Bangle.on('swipe',print)", "text": "Create listener for swipes"}, + {"t":"assert", "js": "Bangle['#onswipe']", "is":"truthy", "text": "Swipe handler registered"}, + {"t":"cmd", "js": "require('messagesoverlay').message('text', {src:'Test',t:'add',type:'text',id:'msg1',title:'First',body:'Message 1'})", "text": "Show first message"}, + {"t":"assertArray", "js": "Bangle['#onswipe']", "is":"undefinedOrEmpty", "text": "Handlers backgrounded after first message"}, + {"t":"cmd", "js": "require('messagesoverlay').message('text', {src:'Test',t:'add',type:'text',id:'msg2',title:'Second',body:'Message 2'})", "text": "Show second message (triggers overlay update)"}, + {"t":"assertArray", "js": "Bangle['#onswipe']", "is":"undefinedOrEmpty", "text": "Handlers still backgrounded after overlay update"}, + {"t":"emit", "event":"touch", "paramsArray": [ 1, { "x": 10, "y": 10, "type": 0 } ], "text": "Close first message"}, + {"t":"assertArray", "js": "Bangle['#onswipe']", "is":"undefinedOrEmpty", "text": "Handlers still backgrounded with message in queue"}, + {"t":"emit", "event":"touch", "paramsArray": [ 1, { "x": 10, "y": 10, "type": 0 } ], "text": "Close second message"}, + {"t":"assert", "js": "Bangle['#onswipe']", "is":"truthy", "text": "Handler restored after all messages closed"} + ] + },{ + "description": "Test watch backgrounding", "steps" : [ {"t":"cmd", "js": "setWatch(print,BTN)", "text": "Create watch"}, {"t":"cmd", "js": "require('messagesoverlay').message('text', {src:'Messenger',t:'add',type:'text',id:Date.now().toFixed(0),title:'title',body:'body'})", "text": "Show a message overlay"}, diff --git a/bin/runapptests.js b/bin/runapptests.js index dcbf13c58c..c1278b115a 100755 --- a/bin/runapptests.js +++ b/bin/runapptests.js @@ -88,6 +88,22 @@ if (!require("fs").existsSync(DIR_IDE)) { const verbose = process.argv.includes("--verbose") || process.argv.includes("-v"); +// Timeout for each test (ms) - prevents infinite loops from hanging CI +const TEST_TIMEOUT_MS = 60000; + +function withTimeout(promise, ms, testName) { + return Promise.race([ + promise, + new Promise((_, reject) => { + setTimeout(() => { + const error = new Error(`Test "${testName}" timed out after ${ms}ms`); + error.isTimeout = true; + reject(error); + }, ms); + }) + ]); +} + var AppInfo = require(BASE_DIR+"/core/js/appinfo.js"); var apploader = require(BASE_DIR+"/core/lib/apploader.js"); apploader.init({ @@ -122,8 +138,8 @@ function assertArray(step){ console.log(`> ASSERT ARRAY ${step.js} IS`,step.is.toUpperCase(), step.text ? "- " + step.text : ""); let isOK; switch (step.is.toLowerCase()){ - case "notempty": isOK = getValue(`${step.js} && ${step.js}.length > 0`); break; - case "undefinedorempty": isOK = getValue(`!${step.js} || (${step.js} && ${step.js}.length === 0)`); break; + case "notempty": isOK = getValue(`(function(v){return v && v.length > 0})(${step.js})`); break; + case "undefinedorempty": isOK = getValue(`(function(v){return !v || v.length === 0})(${step.js})`); break; } if (isOK) { @@ -427,12 +443,21 @@ function runTest(test, testState) { }); p = p.finally(()=>{ + // Check for uncaught errors detected during test + const uncaughtError = getUncaughtError(); + if (uncaughtError) { + console.log("> UNCAUGHT ERROR DETECTED:", uncaughtError); + state.ok = false; + } + resetUncaughtError(); + console.log("> RESULT -", (state.ok ? "OK": "FAIL") , "- " + test.app + (subtest.description ? (" - " + subtest.description) : "")); testState.push({ app: test.app, number: subtestIdx, result: state.ok ? "SUCCESS": "FAILURE", - description: subtest.description + description: subtest.description, + error: uncaughtError || null }); }); }); @@ -445,13 +470,37 @@ function runTest(test, testState) { let handleRx = ()=>{}; -let handleConsoleOutput = () => {}; -if (verbose){ - handleConsoleOutput = (d) => { - console.log("<", d); + +// Uncaught error detection - null means no error, non-empty string contains the error message +let uncaughtError = null; + +function checkForUncaughtError(text) { + // Use precise patterns to avoid false positives (e.g., "ASSERT" matching "assertArray") + if (text && ( + text.match(/Uncaught\b/) || // Uncaught followed by word boundary + text.match(/^ERROR:\s/m) || // ERROR: at start of line only + text.includes("ASSERT FAILED") || // Full assertion failure message + text.match(/^\s+at\s+\S+:\d+:\d+/) // Stack trace: " at file:line:col" + )) { + uncaughtError = text; } } +function resetUncaughtError() { + uncaughtError = null; +} + +function getUncaughtError() { + return uncaughtError; +} + +let handleConsoleOutput = (d) => { + if (verbose) { + console.log("<", d); + } + checkForUncaughtError(d); +}; + let testState = []; emu.init({ @@ -476,7 +525,7 @@ emu.init({ apps = apps.filter(e=>e.id==f); if (apps.length == 0){ console.log("No apps left after filtering for " + f); - process.exitCode(255); + process.exit(255); } } @@ -489,7 +538,28 @@ emu.init({ test.app = app.id; } p = p.then(()=>{ - return runTest(test, testState); + const testName = test.app + (test.description ? ` - ${test.description}` : ''); + return withTimeout(runTest(test, testState), TEST_TIMEOUT_MS, testName) + .catch(err => { + if (err.isTimeout) { + console.log("> TIMEOUT:", err.message); + // Clean up emulator state after timeout + try { + emu.stopIdle(); + } catch (e) { + console.error("Failed to stop emulator after timeout:", e.message); + } + testState.push({ + app: test.app, + number: -1, + result: "TIMEOUT", + description: "Test timed out", + error: err.message + }); + } else { + throw err; + } + }); }); }); p.finally(()=>{ @@ -497,9 +567,12 @@ emu.init({ console.log("Overall results:"); console.table(testState); - process.exit(testState.reduce((a,c)=>{ - return a || ((c.result == "SUCCESS") ? 0 : 1); - }, 0)) + // Exit with appropriate code - count failures and timeouts + const exitCode = testState.reduce((a, c) => { + return a || ((c.result === "SUCCESS") ? 0 : 1); + }, 0); + + process.exit(exitCode); }); return p; });