From 42eedd6d9c3d15663d43f08ced6be8dc7ace2beb Mon Sep 17 00:00:00 2001 From: elijahr Date: Sun, 18 Jan 2026 07:01:44 -0600 Subject: [PATCH 01/10] Add CI functional tests and enhance test runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add functional-tests job to GitHub Actions workflow - Runs app tests for apps with test.json - Smart detection: only tests changed apps on PRs, all on master push - Pins EspruinoWebIDE to specific commit for stability - Enhance bin/runapptests.js test runner - Fix syntax bug on line 479 (exitCode → exit) - Add uncaught error detection (Uncaught, ERROR:, ASSERT, stack traces) - Add 60-second per-test timeout to prevent infinite loop hangs - Add summary table with pass/fail/timeout counts - Add docs/testing.md with test.json format documentation - Fix calendar app bug when no events (menu was malformed array/object hybrid) --- .github/workflows/nodejs.yml | 60 +++++++++++++ apps/calendar/calendar.js | 35 +++++--- bin/runapptests.js | 102 +++++++++++++++++++-- docs/testing.md | 168 +++++++++++++++++++++++++++++++++++ 4 files changed, 343 insertions(+), 22 deletions(-) create mode 100644 docs/testing.md diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index bebe187487..d71b8b23d5 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -25,3 +25,63 @@ 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: | + if [ "${{ github.event_name }}" == "pull_request" ]; then + APPS=$(git diff --name-only ${{ github.event.pull_request.base.sha }}..HEAD \ + | grep '^apps/' | cut -d'/' -f2 | sort -u || true) + else + APPS=$(ls apps/*/test.json 2>/dev/null | cut -d'/' -f2 || echo "") + 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/apps/calendar/calendar.js b/apps/calendar/calendar.js index 526ed41fcc..b06f1527e6 100644 --- a/apps/calendar/calendar.js +++ b/apps/calendar/calendar.js @@ -340,20 +340,29 @@ const setUI = function() { }, touch: (n,e) => { events.sort((a,b) => a.date - b.date); - const menu = events.filter(ev => ev.date.getFullYear() === date.getFullYear() && ev.date.getMonth() === date.getMonth()).map(e => { - const dateStr = require("locale").date(e.date, 1); - const timeStr = require("locale").time(e.date, 1); - return { title: `${dateStr} ${e.type === "e" ? timeStr : ""}` + (e.msg ? " " + e.msg : "") }; - }); - if (menu.length === 0) { - menu.push({title: /*LANG*/"No events"}); - } - menu[""] = { title: require("locale").month(date) + " " + date.getFullYear() }; - menu["< Back"] = () => { - require("widget_utils").hide(); - E.showMenu(); - setUI(); + const filteredEvents = events.filter(ev => ev.date.getFullYear() === date.getFullYear() && ev.date.getMonth() === date.getMonth()); + const menu = { + "" : { title: require("locale").month(date) + " " + date.getFullYear() }, + "< Back": () => { + require("widget_utils").hide(); + E.showMenu(); + setUI(); + } }; + if (filteredEvents.length === 0) { + menu[/*LANG*/"No events"] = () => { + require("widget_utils").hide(); + E.showMenu(); + setUI(); + }; + } else { + filteredEvents.forEach(e => { + const dateStr = require("locale").date(e.date, 1); + const timeStr = require("locale").time(e.date, 1); + const label = `${dateStr} ${e.type === "e" ? timeStr : ""}` + (e.msg ? " " + e.msg : ""); + menu[label] = () => {}; // Placeholder action - could show event details + }); + } require("widget_utils").show(); E.showMenu(menu); } diff --git a/bin/runapptests.js b/bin/runapptests.js index dcbf13c58c..1b2975b6d3 100755 --- a/bin/runapptests.js +++ b/bin/runapptests.js @@ -88,6 +88,18 @@ 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(() => reject(new Error(`Test "${testName}" timed out after ${ms}ms`)), ms) + ) + ]); +} + var AppInfo = require(BASE_DIR+"/core/js/appinfo.js"); var apploader = require(BASE_DIR+"/core/lib/apploader.js"); apploader.init({ @@ -427,12 +439,21 @@ function runTest(test, testState) { }); p = p.finally(()=>{ + // Check for uncaught errors detected during test + const uncaughtError = getUncaughtError(); + if (uncaughtError.detected) { + console.log("> UNCAUGHT ERROR DETECTED:", uncaughtError.message); + 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.detected ? uncaughtError.message : null }); }); }); @@ -445,10 +466,39 @@ function runTest(test, testState) { let handleRx = ()=>{}; -let handleConsoleOutput = () => {}; + +// Uncaught error detection +let uncaughtErrorDetected = false; +let uncaughtErrorMessage = ""; + +function checkForUncaughtError(text) { + if (text && ( + text.includes("Uncaught") || + text.includes("ERROR:") || + text.includes("ASSERT") || + text.match(/^\s*at\s+/) // Stack trace line + )) { + uncaughtErrorDetected = true; + uncaughtErrorMessage = text; + } +} + +function resetUncaughtError() { + uncaughtErrorDetected = false; + uncaughtErrorMessage = ""; +} + +function getUncaughtError() { + return { detected: uncaughtErrorDetected, message: uncaughtErrorMessage }; +} + +let handleConsoleOutput = (d) => { + checkForUncaughtError(d); +}; if (verbose){ handleConsoleOutput = (d) => { console.log("<", d); + checkForUncaughtError(d); } } @@ -476,7 +526,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,17 +539,51 @@ 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.message && err.message.includes('timed out')) { + console.log("> TIMEOUT:", err.message); + testState.push({ + app: test.app, + number: -1, + result: "TIMEOUT", + description: "Test timed out", + error: err.message + }); + } else { + throw err; + } + }); }); }); p.finally(()=>{ - console.log("\n\n"); - console.log("Overall results:"); + // Summary output + console.log("\n"); + console.log("═".repeat(60)); + console.log("TEST RESULTS SUMMARY"); + console.log("═".repeat(60)); console.table(testState); - process.exit(testState.reduce((a,c)=>{ - return a || ((c.result == "SUCCESS") ? 0 : 1); - }, 0)) + // Count results + const passed = testState.filter(t => t.result === "SUCCESS").length; + const failed = testState.filter(t => t.result === "FAILURE").length; + const timedOut = testState.filter(t => t.result === "TIMEOUT").length; + const total = testState.length; + + console.log("─".repeat(60)); + console.log(`Total: ${total} | Passed: ${passed} | Failed: ${failed} | Timeout: ${timedOut}`); + console.log("═".repeat(60)); + + // Exit with appropriate code + const exitCode = (failed > 0 || timedOut > 0) ? 1 : 0; + if (exitCode === 0) { + console.log("✓ All tests passed!"); + } else { + console.log("✗ Some tests failed."); + } + + process.exit(exitCode); }); return p; }); diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000000..5f1d77e5e4 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,168 @@ +# 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"} + ] + } + ] +} +``` + +### Supported Step Types + +| Type | Description | Example | +|------|-------------|---------| +| `cmd` | Execute JavaScript on device | `{"t":"cmd", "js": "global.x = 1"}` | +| `eval` | Evaluate and compare result | `{"t":"eval", "js": "'a'+'b'", "eq": "ab"}` | +| `assert` | Assert a condition | `{"t":"assert", "js": "x > 0", "is": "truthy"}` | +| `assertArray` | Assert array conditions | `{"t":"assertArray", "js": "arr", "is": "notEmpty"}` | +| `setup` | Call a predefined setup | `{"t":"setup", "id": "default"}` | +| `load` | Load a file on device | `{"t":"load", "fn": "myapp.app.js"}` | +| `wrap` | Wrap a function for tracking | `{"t":"wrap", "fn": "Bangle.setGPSPower", "id": "gps"}` | +| `assertCall` | Assert function was called | `{"t":"assertCall", "id": "gps", "count": 1}` | +| `resetCall` | Reset call tracking | `{"t":"resetCall", "id": "gps"}` | +| `emit` | Emit an event | `{"t":"emit", "event": "touch", "paramsArray": [1, {"x":10}]}` | +| `gb` | Simulate Gadgetbridge message | `{"t":"gb", "obj": {"t": "notify"}}` | +| `advanceTimers` | Advance emulator timers | `{"t":"advanceTimers", "ms": 60000}` | +| `saveMemoryUsage` | Store current memory | `{"t":"saveMemoryUsage"}` | +| `checkMemoryUsage` | Compare to stored memory | `{"t":"checkMemoryUsage"}` | +| `upload` | Upload a module | `{"t":"upload", "file": "modules/foo.js", "as": "foo"}` | + +### Assert Conditions + +For `assert` steps, the `is` field supports: + +- `truthy` - Value is truthy +- `falsy` - Value is falsy +- `true` - Value is exactly `true` +- `false` - Value is exactly `false` +- `equal` - Value equals `to` field +- `function` - Value is a function + +For `assertArray` steps: + +- `notEmpty` - Array has elements +- `undefinedOrEmpty` - Array is undefined or empty + +### 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) + +### Uncaught Error Detection + +The test runner detects uncaught errors in the emulator console output. If any of these patterns are found, the test fails: + +- `Uncaught` +- `ERROR:` +- `ASSERT` +- Stack trace lines (starting with `at`) + +## Apps with Tests + +Currently, these apps have functional tests: + +- `android` - GPS power management (5 tests) +- `messagesoverlay` - Handler backgrounding (3 tests) +- `measuretime` - Memory usage +- `antonclk` - Memory usage + +## 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. From 5644ac703d5b0f2325e91066609c0704b003ee0f Mon Sep 17 00:00:00 2001 From: elijahr Date: Sun, 18 Jan 2026 07:29:40 -0600 Subject: [PATCH 02/10] Address code review feedback and fix CI trigger - calendar: Make "No events" a non-selectable label (undefined) - runapptests: Remove handleConsoleOutput duplication - CI: Only run all tests on push to master, not feature branches Feature branch pushes now test only changed apps since master --- .github/workflows/nodejs.yml | 8 +++++++- apps/calendar/calendar.js | 6 +----- bin/runapptests.js | 9 +++------ 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index d71b8b23d5..c4b6b43f80 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -52,10 +52,16 @@ jobs: id: detect-apps run: | if [ "${{ github.event_name }}" == "pull_request" ]; then + # PR: test only changed apps APPS=$(git diff --name-only ${{ github.event.pull_request.base.sha }}..HEAD \ | grep '^apps/' | cut -d'/' -f2 | sort -u || true) - else + elif [ "${{ github.ref }}" == "refs/heads/master" ]; then + # Push to master: test all apps APPS=$(ls apps/*/test.json 2>/dev/null | cut -d'/' -f2 || echo "") + else + # Push to other branches: test only changed apps since master + APPS=$(git diff --name-only origin/master...HEAD \ + | grep '^apps/' | cut -d'/' -f2 | sort -u || true) fi # Filter to apps with test.json TESTABLE="" diff --git a/apps/calendar/calendar.js b/apps/calendar/calendar.js index b06f1527e6..3e282dcf94 100644 --- a/apps/calendar/calendar.js +++ b/apps/calendar/calendar.js @@ -350,11 +350,7 @@ const setUI = function() { } }; if (filteredEvents.length === 0) { - menu[/*LANG*/"No events"] = () => { - require("widget_utils").hide(); - E.showMenu(); - setUI(); - }; + menu[/*LANG*/"No events"] = undefined; // Non-selectable label } else { filteredEvents.forEach(e => { const dateStr = require("locale").date(e.date, 1); diff --git a/bin/runapptests.js b/bin/runapptests.js index 1b2975b6d3..6f5e76bd19 100755 --- a/bin/runapptests.js +++ b/bin/runapptests.js @@ -493,14 +493,11 @@ function getUncaughtError() { } let handleConsoleOutput = (d) => { - checkForUncaughtError(d); -}; -if (verbose){ - handleConsoleOutput = (d) => { + if (verbose) { console.log("<", d); - checkForUncaughtError(d); } -} + checkForUncaughtError(d); +}; let testState = []; From 0725ca748bb6bb370a0327d30445eaa39bdb2d5b Mon Sep 17 00:00:00 2001 From: elijahr Date: Sun, 18 Jan 2026 16:04:18 -0600 Subject: [PATCH 03/10] Fix messagesoverlay premature handler restoration bug The show() function was registering a remove:cleanup callback on every call to setLCDOverlay(). During showMessage(), show() is called twice (via drawBorder and drawMessage). When the second call replaced the first overlay with the same ID, Espruino triggered the first overlay's remove callback, causing premature handler restoration. Fix: Track overlay state with overlayShowing flag and only register the remove callback on the first show() call. Also enhanced assertArray in test runner to treat arrays containing only undefined/null values as empty. --- apps/messagesoverlay/lib.js | 11 ++++++++++- apps/messagesoverlay/test.json | 6 +++--- bin/runapptests.js | 3 ++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/messagesoverlay/lib.js b/apps/messagesoverlay/lib.js index 9119999bd6..68a27f72df 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,14 @@ 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"}; + if (!overlayShowing) { + opts.remove = cleanup; + overlayShowing = true; + } + Bangle.setLCDOverlay(img, ovrx, ovry, opts); }; const manageEvent = function(event) { @@ -620,6 +628,7 @@ const cleanup = function(){ Bangle.setLCDOverlay(undefined, {id: "messagesoverlay"}); ovr = undefined; + overlayShowing = false; }; const backup = {}; diff --git a/apps/messagesoverlay/test.json b/apps/messagesoverlay/test.json index 77e12caa66..4e6ecc37c8 100644 --- a/apps/messagesoverlay/test.json +++ b/apps/messagesoverlay/test.json @@ -7,13 +7,13 @@ {"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)", diff --git a/bin/runapptests.js b/bin/runapptests.js index 6f5e76bd19..a53d92c3e5 100755 --- a/bin/runapptests.js +++ b/bin/runapptests.js @@ -135,7 +135,8 @@ function assertArray(step){ 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; + // Check if undefined, null, empty array, or array containing only undefined/null values + case "undefinedorempty": isOK = getValue(`!${step.js} || (${step.js} && (${step.js}.length === 0 || (Array.isArray(${step.js}) && ${step.js}.every(function(x){return x===undefined||x===null}))))`); break; } if (isOK) { From 1a90a369f3f810424ab4e1c4f9cab7a1dea2c0a2 Mon Sep 17 00:00:00 2001 From: elijahr Date: Sun, 18 Jan 2026 16:09:10 -0600 Subject: [PATCH 04/10] Update messagesoverlay ChangeLog for 0.12 --- apps/messagesoverlay/ChangeLog | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/messagesoverlay/ChangeLog b/apps/messagesoverlay/ChangeLog index 094694c0d7..5db9da88c4 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 \ No newline at end of file From db81c48c33d071c61b0dee60f796fb7b26e01f6e Mon Sep 17 00:00:00 2001 From: elijahr Date: Sun, 18 Jan 2026 17:19:51 -0600 Subject: [PATCH 05/10] Address code review feedback and fix CI trigger - Fix messagesoverlay premature cleanup on overlay update - Add regression test for overlay update scenario - Fix error detection false positives in test runner (ASSERT vs assertArray) - Fix triple evaluation in assertArray with IIFE pattern - Add timeout cleanup with emu.stopIdle() - Fix calendar duplicate event labels overwriting menu items - Fix CI workflow error suppression (separate git/grep) - Update docs and ChangeLog --- .github/workflows/nodejs.yml | 11 ++++++----- apps/calendar/calendar.js | 19 ++++++++++++++----- apps/messagesoverlay/ChangeLog | 2 +- apps/messagesoverlay/lib.js | 8 ++++++-- apps/messagesoverlay/test.json | 16 +++++++++++++++- bin/runapptests.js | 20 ++++++++++++++------ docs/testing.md | 10 +++++----- 7 files changed, 61 insertions(+), 25 deletions(-) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index c4b6b43f80..409ca0787f 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -51,17 +51,18 @@ jobs: - 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 - APPS=$(git diff --name-only ${{ github.event.pull_request.base.sha }}..HEAD \ - | grep '^apps/' | cut -d'/' -f2 | sort -u || true) + 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 || echo "") + APPS=$(ls apps/*/test.json 2>/dev/null | cut -d'/' -f2) || APPS="" else # Push to other branches: test only changed apps since master - APPS=$(git diff --name-only origin/master...HEAD \ - | grep '^apps/' | cut -d'/' -f2 | sort -u || true) + 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="" diff --git a/apps/calendar/calendar.js b/apps/calendar/calendar.js index 3e282dcf94..0fa69b1c28 100644 --- a/apps/calendar/calendar.js +++ b/apps/calendar/calendar.js @@ -350,12 +350,21 @@ const setUI = function() { } }; if (filteredEvents.length === 0) { - menu[/*LANG*/"No events"] = undefined; // Non-selectable label + // undefined value creates a non-selectable menu item (Bangle.js convention) + menu[/*LANG*/"No events"] = undefined; } else { - filteredEvents.forEach(e => { - const dateStr = require("locale").date(e.date, 1); - const timeStr = require("locale").time(e.date, 1); - const label = `${dateStr} ${e.type === "e" ? timeStr : ""}` + (e.msg ? " " + e.msg : ""); + const usedLabels = {}; + filteredEvents.forEach(evt => { + const dateStr = require("locale").date(evt.date, 1); + const timeStr = require("locale").time(evt.date, 1); + let label = `${dateStr} ${evt.type === "e" ? timeStr : ""}` + (evt.msg ? " " + evt.msg : ""); + // Handle duplicate labels by appending a counter + if (usedLabels[label]) { + usedLabels[label]++; + label = `${label} (${usedLabels[label]})`; + } else { + usedLabels[label] = 1; + } menu[label] = () => {}; // Placeholder action - could show event details }); } diff --git a/apps/messagesoverlay/ChangeLog b/apps/messagesoverlay/ChangeLog index 5db9da88c4..f32a842de9 100644 --- a/apps/messagesoverlay/ChangeLog +++ b/apps/messagesoverlay/ChangeLog @@ -15,4 +15,4 @@ 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 -0.12: Fix premature handler restoration when overlay is updated during message display \ No newline at end of file +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 68a27f72df..a3eab17345 100644 --- a/apps/messagesoverlay/lib.js +++ b/apps/messagesoverlay/lib.js @@ -59,11 +59,15 @@ const show = function(){ // Only register remove callback on first show to avoid premature cleanup // when setLCDOverlay replaces existing overlay with same ID const opts = {id:"messagesoverlay"}; - if (!overlayShowing) { + const isFirstShow = !overlayShowing; + if (isFirstShow) { opts.remove = cleanup; - overlayShowing = true; } 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) { diff --git a/apps/messagesoverlay/test.json b/apps/messagesoverlay/test.json index 4e6ecc37c8..b908da1f6b 100644 --- a/apps/messagesoverlay/test.json +++ b/apps/messagesoverlay/test.json @@ -28,7 +28,21 @@ {"t":"assert", "js": "Bangle['#onswipe']", "is":"function", "text": "One 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 a53d92c3e5..f542d5f085 100755 --- a/bin/runapptests.js +++ b/bin/runapptests.js @@ -134,9 +134,10 @@ 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; + // Evaluate expression once to avoid side effects from multiple evaluations + case "notempty": isOK = getValue(`(function(v){return v && v.length > 0})(${step.js})`); break; // Check if undefined, null, empty array, or array containing only undefined/null values - case "undefinedorempty": isOK = getValue(`!${step.js} || (${step.js} && (${step.js}.length === 0 || (Array.isArray(${step.js}) && ${step.js}.every(function(x){return x===undefined||x===null}))))`); break; + case "undefinedorempty": isOK = getValue(`(function(v){return !v || v.length === 0 || (Array.isArray(v) && v.every(function(x){return x==null}))})(${step.js})`); break; } if (isOK) { @@ -473,11 +474,12 @@ let uncaughtErrorDetected = false; let uncaughtErrorMessage = ""; function checkForUncaughtError(text) { + // Use precise patterns to avoid false positives (e.g., "ASSERT" matching "assertArray") if (text && ( - text.includes("Uncaught") || - text.includes("ERROR:") || - text.includes("ASSERT") || - text.match(/^\s*at\s+/) // Stack trace line + text.includes("Uncaught ") || // Space after to avoid "UncaughtFoo" + 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" )) { uncaughtErrorDetected = true; uncaughtErrorMessage = text; @@ -542,6 +544,12 @@ emu.init({ .catch(err => { if (err.message && err.message.includes('timed out')) { 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, diff --git a/docs/testing.md b/docs/testing.md index 5f1d77e5e4..63131b173c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -131,17 +131,17 @@ Tests run automatically in GitHub Actions: The test runner detects uncaught errors in the emulator console output. If any of these patterns are found, the test fails: -- `Uncaught` -- `ERROR:` -- `ASSERT` -- Stack trace lines (starting with `at`) +- `Uncaught ` (with trailing space) +- `ERROR:` at the start of a line +- `ASSERT FAILED` +- Stack trace lines (pattern: ` at file:line:col`) ## Apps with Tests Currently, these apps have functional tests: - `android` - GPS power management (5 tests) -- `messagesoverlay` - Handler backgrounding (3 tests) +- `messagesoverlay` - Handler backgrounding (4 tests) - `measuretime` - Memory usage - `antonclk` - Memory usage From 90d54f8155c301f762a16ee8933a23641a5925e0 Mon Sep 17 00:00:00 2001 From: elijahr Date: Tue, 20 Jan 2026 02:14:30 -0600 Subject: [PATCH 06/10] Add back button to agenda "No events" screen Show message with proper back navigation instead of blocking alert when there are no calendar events. --- apps/agenda/ChangeLog | 1 + apps/agenda/agenda.js | 1 + apps/agenda/metadata.json | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) 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"}], From 644181162dcf645531223f9ff2f2caf2558ec5f5 Mon Sep 17 00:00:00 2001 From: elijahr Date: Tue, 20 Jan 2026 02:16:10 -0600 Subject: [PATCH 07/10] Add allNullish assertion for sparse array checks Distinguishes between truly empty arrays and arrays containing only null/undefined values (e.g., sparse arrays from backgrounded handlers). --- apps/messagesoverlay/test.json | 26 +++++++++++++------------- bin/runapptests.js | 5 +++-- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/apps/messagesoverlay/test.json b/apps/messagesoverlay/test.json index b908da1f6b..f2b14b66fe 100644 --- a/apps/messagesoverlay/test.json +++ b/apps/messagesoverlay/test.json @@ -1,52 +1,52 @@ { "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":"assertArray", "js": "Bangle['#onswipe']", "is":"allNullish", "text": "No swipe handlers"}, {"t":"cmd", "js": "require('widget_utils').swipeOn(0)", "text": "Store widgets in overlay"}, {"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": "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":"assertArray", "js": "Bangle['#onswipe']", "is":"allNullish", "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": "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"}, {"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":"assertArray", "js": "Bangle['#onswipe']", "is":"allNullish", "text": "No swipe handlers while message overlay is on screen"}, {"t":"cmd", "js": "Bangle.setUI()", "text": "Trigger removal of UI"}, - {"t":"assertArray", "js": "Bangle['#onswipe']", "is":"undefinedOrEmpty", "text": "Still no swipe handlers"}, + {"t":"assertArray", "js": "Bangle['#onswipe']", "is":"allNullish", "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'].filter(x=>x!=null).length", "is":"equal", "to": "1", "text": "One swipe handler restored"} ] },{ "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":"assert", "js": "Bangle['#onswipe'].filter(x=>x!=null).length", "is":"equal", "to": "1", "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":"assertArray", "js": "Bangle['#onswipe']", "is":"allNullish", "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":"assertArray", "js": "Bangle['#onswipe']", "is":"allNullish", "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":"assertArray", "js": "Bangle['#onswipe']", "is":"allNullish", "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"} + {"t":"assert", "js": "Bangle['#onswipe'].filter(x=>x!=null).length", "is":"equal", "to": "1", "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"}, - {"t":"assertArray", "js": "global[\"\\xff\"].watches", "is":"undefinedOrEmpty", "text": "No watches while message overlay is on screen"}, + {"t":"assertArray", "js": "global[\"\\xff\"].watches", "is":"allNullish", "text": "No watches while message overlay is on screen"}, {"t":"emit", "event":"touch", "paramsArray": [ 1, { "x": 10, "y": 10, "type": 0 } ], "text": "Close message"}, {"t":"assert", "js": "global[\"\\xff\"].watches.length", "is":"equal", "to": "2", "text": "One watch restored, first entry is always empty"} ] diff --git a/bin/runapptests.js b/bin/runapptests.js index f542d5f085..ed9fa098c3 100755 --- a/bin/runapptests.js +++ b/bin/runapptests.js @@ -136,8 +136,9 @@ function assertArray(step){ switch (step.is.toLowerCase()){ // Evaluate expression once to avoid side effects from multiple evaluations case "notempty": isOK = getValue(`(function(v){return v && v.length > 0})(${step.js})`); break; - // Check if undefined, null, empty array, or array containing only undefined/null values - case "undefinedorempty": isOK = getValue(`(function(v){return !v || v.length === 0 || (Array.isArray(v) && v.every(function(x){return x==null}))})(${step.js})`); break; + case "undefinedorempty": isOK = getValue(`(function(v){return !v || v.length === 0})(${step.js})`); break; + // For arrays that may contain only null/undefined (e.g., sparse arrays from backgrounded handlers) + case "allnullish": isOK = getValue(`(function(v){return !v || v.length === 0 || (Array.isArray(v) && v.every(function(x){return x==null}))})(${step.js})`); break; } if (isOK) { From e94b090cea233cd903436f4b9793aad3e00fdf8c Mon Sep 17 00:00:00 2001 From: elijahr Date: Tue, 20 Jan 2026 02:16:14 -0600 Subject: [PATCH 08/10] Simplify calendar events menu construction --- apps/calendar/calendar.js | 40 +++++++++++++-------------------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/apps/calendar/calendar.js b/apps/calendar/calendar.js index 0fa69b1c28..526ed41fcc 100644 --- a/apps/calendar/calendar.js +++ b/apps/calendar/calendar.js @@ -340,34 +340,20 @@ const setUI = function() { }, touch: (n,e) => { events.sort((a,b) => a.date - b.date); - const filteredEvents = events.filter(ev => ev.date.getFullYear() === date.getFullYear() && ev.date.getMonth() === date.getMonth()); - const menu = { - "" : { title: require("locale").month(date) + " " + date.getFullYear() }, - "< Back": () => { - require("widget_utils").hide(); - E.showMenu(); - setUI(); - } - }; - if (filteredEvents.length === 0) { - // undefined value creates a non-selectable menu item (Bangle.js convention) - menu[/*LANG*/"No events"] = undefined; - } else { - const usedLabels = {}; - filteredEvents.forEach(evt => { - const dateStr = require("locale").date(evt.date, 1); - const timeStr = require("locale").time(evt.date, 1); - let label = `${dateStr} ${evt.type === "e" ? timeStr : ""}` + (evt.msg ? " " + evt.msg : ""); - // Handle duplicate labels by appending a counter - if (usedLabels[label]) { - usedLabels[label]++; - label = `${label} (${usedLabels[label]})`; - } else { - usedLabels[label] = 1; - } - menu[label] = () => {}; // Placeholder action - could show event details - }); + const menu = events.filter(ev => ev.date.getFullYear() === date.getFullYear() && ev.date.getMonth() === date.getMonth()).map(e => { + const dateStr = require("locale").date(e.date, 1); + const timeStr = require("locale").time(e.date, 1); + return { title: `${dateStr} ${e.type === "e" ? timeStr : ""}` + (e.msg ? " " + e.msg : "") }; + }); + if (menu.length === 0) { + menu.push({title: /*LANG*/"No events"}); } + menu[""] = { title: require("locale").month(date) + " " + date.getFullYear() }; + menu["< Back"] = () => { + require("widget_utils").hide(); + E.showMenu(); + setUI(); + }; require("widget_utils").show(); E.showMenu(menu); } From d190012a4894bcd8a9c372a69af58398b5523841 Mon Sep 17 00:00:00 2001 From: elijahr Date: Tue, 20 Jan 2026 06:56:40 -0600 Subject: [PATCH 09/10] Address PR review feedback - Fix bashism in nodejs.yml (== to =) - Use regex for Uncaught detection in test runner - Combine error detection into single uncaughtError variable - Use isTimeout flag instead of string matching - Remove unicode and header/divider lines from summary output - Move docs/testing.md to TESTING.md, trim API sections - Link TESTING.md from README.md - Bump messagesoverlay version to 0.12 --- .github/workflows/nodejs.yml | 4 +- README.md | 2 + docs/testing.md => TESTING.md | 54 +------------------------- apps/messagesoverlay/metadata.json | 2 +- bin/runapptests.js | 62 ++++++++++++------------------ 5 files changed, 30 insertions(+), 94 deletions(-) rename docs/testing.md => TESTING.md (53%) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 409ca0787f..871b138259 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -52,11 +52,11 @@ jobs: id: detect-apps run: | set -e # Exit on error, but handle grep specially - if [ "${{ github.event_name }}" == "pull_request" ]; then + 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 + 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 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/docs/testing.md b/TESTING.md similarity index 53% rename from docs/testing.md rename to TESTING.md index 63131b173c..2189d8f949 100644 --- a/docs/testing.md +++ b/TESTING.md @@ -54,41 +54,7 @@ Create a `test.json` file in your app's directory (e.g., `apps/myapp/test.json`) } ``` -### Supported Step Types - -| Type | Description | Example | -|------|-------------|---------| -| `cmd` | Execute JavaScript on device | `{"t":"cmd", "js": "global.x = 1"}` | -| `eval` | Evaluate and compare result | `{"t":"eval", "js": "'a'+'b'", "eq": "ab"}` | -| `assert` | Assert a condition | `{"t":"assert", "js": "x > 0", "is": "truthy"}` | -| `assertArray` | Assert array conditions | `{"t":"assertArray", "js": "arr", "is": "notEmpty"}` | -| `setup` | Call a predefined setup | `{"t":"setup", "id": "default"}` | -| `load` | Load a file on device | `{"t":"load", "fn": "myapp.app.js"}` | -| `wrap` | Wrap a function for tracking | `{"t":"wrap", "fn": "Bangle.setGPSPower", "id": "gps"}` | -| `assertCall` | Assert function was called | `{"t":"assertCall", "id": "gps", "count": 1}` | -| `resetCall` | Reset call tracking | `{"t":"resetCall", "id": "gps"}` | -| `emit` | Emit an event | `{"t":"emit", "event": "touch", "paramsArray": [1, {"x":10}]}` | -| `gb` | Simulate Gadgetbridge message | `{"t":"gb", "obj": {"t": "notify"}}` | -| `advanceTimers` | Advance emulator timers | `{"t":"advanceTimers", "ms": 60000}` | -| `saveMemoryUsage` | Store current memory | `{"t":"saveMemoryUsage"}` | -| `checkMemoryUsage` | Compare to stored memory | `{"t":"checkMemoryUsage"}` | -| `upload` | Upload a module | `{"t":"upload", "file": "modules/foo.js", "as": "foo"}` | - -### Assert Conditions - -For `assert` steps, the `is` field supports: - -- `truthy` - Value is truthy -- `falsy` - Value is falsy -- `true` - Value is exactly `true` -- `false` - Value is exactly `false` -- `equal` - Value equals `to` field -- `function` - Value is a function - -For `assertArray` steps: - -- `notEmpty` - Array has elements -- `undefinedOrEmpty` - Array is undefined or empty +For available step types and assert conditions, see the inline documentation in `bin/runapptests.js`. ### Example: Testing GPS Power Management @@ -127,24 +93,6 @@ Tests run automatically in GitHub Actions: - **FAILURE**: One or more assertions failed - **TIMEOUT**: Test took longer than 60 seconds (possible infinite loop) -### Uncaught Error Detection - -The test runner detects uncaught errors in the emulator console output. If any of these patterns are found, the test fails: - -- `Uncaught ` (with trailing space) -- `ERROR:` at the start of a line -- `ASSERT FAILED` -- Stack trace lines (pattern: ` at file:line:col`) - -## Apps with Tests - -Currently, these apps have functional tests: - -- `android` - GPS power management (5 tests) -- `messagesoverlay` - Handler backgrounding (4 tests) -- `measuretime` - Memory usage -- `antonclk` - Memory usage - ## Troubleshooting ### "You need to git clone EspruinoWebIDE" 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/bin/runapptests.js b/bin/runapptests.js index ed9fa098c3..9b1e7a870e 100755 --- a/bin/runapptests.js +++ b/bin/runapptests.js @@ -94,9 +94,13 @@ const TEST_TIMEOUT_MS = 60000; function withTimeout(promise, ms, testName) { return Promise.race([ promise, - new Promise((_, reject) => - setTimeout(() => reject(new Error(`Test "${testName}" timed out after ${ms}ms`)), ms) - ) + new Promise((_, reject) => { + setTimeout(() => { + const error = new Error(`Test "${testName}" timed out after ${ms}ms`); + error.isTimeout = true; + reject(error); + }, ms); + }) ]); } @@ -138,6 +142,7 @@ function assertArray(step){ 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; // For arrays that may contain only null/undefined (e.g., sparse arrays from backgrounded handlers) + // Separate assertion type as suggested in code review - [null] would not intuitively pass "undefinedorempty" case "allnullish": isOK = getValue(`(function(v){return !v || v.length === 0 || (Array.isArray(v) && v.every(function(x){return x==null}))})(${step.js})`); break; } @@ -444,8 +449,8 @@ function runTest(test, testState) { p = p.finally(()=>{ // Check for uncaught errors detected during test const uncaughtError = getUncaughtError(); - if (uncaughtError.detected) { - console.log("> UNCAUGHT ERROR DETECTED:", uncaughtError.message); + if (uncaughtError) { + console.log("> UNCAUGHT ERROR DETECTED:", uncaughtError); state.ok = false; } resetUncaughtError(); @@ -456,7 +461,7 @@ function runTest(test, testState) { number: subtestIdx, result: state.ok ? "SUCCESS": "FAILURE", description: subtest.description, - error: uncaughtError.detected ? uncaughtError.message : null + error: uncaughtError || null }); }); }); @@ -470,30 +475,27 @@ function runTest(test, testState) { let handleRx = ()=>{}; -// Uncaught error detection -let uncaughtErrorDetected = false; -let uncaughtErrorMessage = ""; +// 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.includes("Uncaught ") || // Space after to avoid "UncaughtFoo" + 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" )) { - uncaughtErrorDetected = true; - uncaughtErrorMessage = text; + uncaughtError = text; } } function resetUncaughtError() { - uncaughtErrorDetected = false; - uncaughtErrorMessage = ""; + uncaughtError = null; } function getUncaughtError() { - return { detected: uncaughtErrorDetected, message: uncaughtErrorMessage }; + return uncaughtError; } let handleConsoleOutput = (d) => { @@ -543,7 +545,7 @@ emu.init({ const testName = test.app + (test.description ? ` - ${test.description}` : ''); return withTimeout(runTest(test, testState), TEST_TIMEOUT_MS, testName) .catch(err => { - if (err.message && err.message.includes('timed out')) { + if (err.isTimeout) { console.log("> TIMEOUT:", err.message); // Clean up emulator state after timeout try { @@ -565,30 +567,14 @@ emu.init({ }); }); p.finally(()=>{ - // Summary output - console.log("\n"); - console.log("═".repeat(60)); - console.log("TEST RESULTS SUMMARY"); - console.log("═".repeat(60)); + console.log("\n\n"); + console.log("Overall results:"); console.table(testState); - // Count results - const passed = testState.filter(t => t.result === "SUCCESS").length; - const failed = testState.filter(t => t.result === "FAILURE").length; - const timedOut = testState.filter(t => t.result === "TIMEOUT").length; - const total = testState.length; - - console.log("─".repeat(60)); - console.log(`Total: ${total} | Passed: ${passed} | Failed: ${failed} | Timeout: ${timedOut}`); - console.log("═".repeat(60)); - - // Exit with appropriate code - const exitCode = (failed > 0 || timedOut > 0) ? 1 : 0; - if (exitCode === 0) { - console.log("✓ All tests passed!"); - } else { - console.log("✗ Some tests failed."); - } + // 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); }); From 342bf892453dcb67eaacdd38b5eaae7b703cec94 Mon Sep 17 00:00:00 2001 From: elijahr Date: Mon, 2 Feb 2026 23:11:27 -0600 Subject: [PATCH 10/10] Fix test assertions for Espruino compatibility - Remove allNullish assertion (unnecessary, undefinedOrEmpty works) - Replace .filter() calls with truthy checks (Espruino lacks filter) --- apps/messagesoverlay/test.json | 22 +++++++++++----------- bin/runapptests.js | 4 ---- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/apps/messagesoverlay/test.json b/apps/messagesoverlay/test.json index f2b14b66fe..0917c4d1ad 100644 --- a/apps/messagesoverlay/test.json +++ b/apps/messagesoverlay/test.json @@ -5,13 +5,13 @@ "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":"allNullish", "text": "No swipe handlers"}, + {"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": "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": "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":"allNullish", "text": "No swipe handlers while message overlay is on screen"}, + {"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": "require('widget_utils').swipeHandler", "is":"function", "text": "Swipe handler restored on widget_utils"} ] @@ -21,32 +21,32 @@ {"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"}, {"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":"allNullish", "text": "No swipe handlers while message overlay is on screen"}, + {"t":"assertArray", "js": "Bangle['#onswipe']", "is":"undefinedOrEmpty", "text": "No swipe handlers while message overlay is on screen"}, {"t":"cmd", "js": "Bangle.setUI()", "text": "Trigger removal of UI"}, - {"t":"assertArray", "js": "Bangle['#onswipe']", "is":"allNullish", "text": "Still no swipe handlers"}, + {"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'].filter(x=>x!=null).length", "is":"equal", "to": "1", "text": "One swipe handler restored"} + {"t":"assert", "js": "Bangle['#onswipe']", "is":"truthy", "text": "Swipe handler restored"} ] },{ "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'].filter(x=>x!=null).length", "is":"equal", "to": "1", "text": "Swipe handler registered"}, + {"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":"allNullish", "text": "Handlers backgrounded after 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":"allNullish", "text": "Handlers still backgrounded after 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":"allNullish", "text": "Handlers still backgrounded with message in queue"}, + {"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'].filter(x=>x!=null).length", "is":"equal", "to": "1", "text": "Handler restored after all messages closed"} + {"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"}, - {"t":"assertArray", "js": "global[\"\\xff\"].watches", "is":"allNullish", "text": "No watches while message overlay is on screen"}, + {"t":"assertArray", "js": "global[\"\\xff\"].watches", "is":"undefinedOrEmpty", "text": "No watches while message overlay is on screen"}, {"t":"emit", "event":"touch", "paramsArray": [ 1, { "x": 10, "y": 10, "type": 0 } ], "text": "Close message"}, {"t":"assert", "js": "global[\"\\xff\"].watches.length", "is":"equal", "to": "2", "text": "One watch restored, first entry is always empty"} ] diff --git a/bin/runapptests.js b/bin/runapptests.js index 9b1e7a870e..c1278b115a 100755 --- a/bin/runapptests.js +++ b/bin/runapptests.js @@ -138,12 +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()){ - // Evaluate expression once to avoid side effects from multiple evaluations 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; - // For arrays that may contain only null/undefined (e.g., sparse arrays from backgrounded handlers) - // Separate assertion type as suggested in code review - [null] would not intuitively pass "undefinedorempty" - case "allnullish": isOK = getValue(`(function(v){return !v || v.length === 0 || (Array.isArray(v) && v.every(function(x){return x==null}))})(${step.js})`); break; } if (isOK) {