From 011fe76142b798cd365212542f52c65e88e3a7d3 Mon Sep 17 00:00:00 2001 From: Joe Citrine Date: Tue, 19 May 2026 18:01:46 +0100 Subject: [PATCH 1/7] On device test app --- .gitignore | 4 +- tests/test-app/README.md | 36 +++++ tests/test-app/package-lock.json | 52 +++++++ tests/test-app/package.json | 40 +++++ tests/test-app/src/c/main.c | 39 +++++ tests/test-app/src/c/runner.c | 91 +++++++++++ tests/test-app/src/c/runner.h | 38 +++++ tests/test-app/src/c/tests.h | 19 +++ tests/test-app/src/c/tests/appmessage.c | 11 ++ tests/test-app/src/c/tests/fetch.c | 14 ++ tests/test-app/src/c/tests/registry.c | 24 +++ tests/test-app/src/c/tests/timeline.c | 13 ++ tests/test-app/src/c/ui.h | 5 + tests/test-app/src/c/windows/category_menu.c | 67 +++++++++ tests/test-app/src/c/windows/category_menu.h | 5 + tests/test-app/src/c/windows/detail.c | 74 +++++++++ tests/test-app/src/c/windows/detail.h | 5 + tests/test-app/src/c/windows/main_menu.c | 68 +++++++++ tests/test-app/src/c/windows/main_menu.h | 6 + tests/test-app/src/c/windows/report.c | 141 ++++++++++++++++++ tests/test-app/src/c/windows/report.h | 6 + tests/test-app/src/ts/index.ts | 36 +++++ tests/test-app/src/ts/reply.ts | 9 ++ .../src/ts/tests/deleteTimelinePin.ts | 10 ++ tests/test-app/src/ts/tests/fetchBinary.ts | 11 ++ tests/test-app/src/ts/tests/fetchJSON.ts | 11 ++ tests/test-app/src/ts/tests/fetchString.ts | 11 ++ .../test-app/src/ts/tests/getTimelineToken.ts | 9 ++ tests/test-app/src/ts/tests/index.ts | 28 ++++ .../src/ts/tests/insertTimelinePin.ts | 22 +++ tests/test-app/src/ts/tests/nativeFetch.ts | 11 ++ tests/test-app/src/ts/tests/sendAppMessage.ts | 7 + .../test-app/src/ts/tests/timelinePinState.ts | 11 ++ tests/test-app/tsconfig.json | 4 + tests/test-app/wscript | 56 +++++++ 35 files changed, 993 insertions(+), 1 deletion(-) create mode 100644 tests/test-app/README.md create mode 100644 tests/test-app/package-lock.json create mode 100644 tests/test-app/package.json create mode 100644 tests/test-app/src/c/main.c create mode 100644 tests/test-app/src/c/runner.c create mode 100644 tests/test-app/src/c/runner.h create mode 100644 tests/test-app/src/c/tests.h create mode 100644 tests/test-app/src/c/tests/appmessage.c create mode 100644 tests/test-app/src/c/tests/fetch.c create mode 100644 tests/test-app/src/c/tests/registry.c create mode 100644 tests/test-app/src/c/tests/timeline.c create mode 100644 tests/test-app/src/c/ui.h create mode 100644 tests/test-app/src/c/windows/category_menu.c create mode 100644 tests/test-app/src/c/windows/category_menu.h create mode 100644 tests/test-app/src/c/windows/detail.c create mode 100644 tests/test-app/src/c/windows/detail.h create mode 100644 tests/test-app/src/c/windows/main_menu.c create mode 100644 tests/test-app/src/c/windows/main_menu.h create mode 100644 tests/test-app/src/c/windows/report.c create mode 100644 tests/test-app/src/c/windows/report.h create mode 100644 tests/test-app/src/ts/index.ts create mode 100644 tests/test-app/src/ts/reply.ts create mode 100644 tests/test-app/src/ts/tests/deleteTimelinePin.ts create mode 100644 tests/test-app/src/ts/tests/fetchBinary.ts create mode 100644 tests/test-app/src/ts/tests/fetchJSON.ts create mode 100644 tests/test-app/src/ts/tests/fetchString.ts create mode 100644 tests/test-app/src/ts/tests/getTimelineToken.ts create mode 100644 tests/test-app/src/ts/tests/index.ts create mode 100644 tests/test-app/src/ts/tests/insertTimelinePin.ts create mode 100644 tests/test-app/src/ts/tests/nativeFetch.ts create mode 100644 tests/test-app/src/ts/tests/sendAppMessage.ts create mode 100644 tests/test-app/src/ts/tests/timelinePinState.ts create mode 100644 tests/test-app/tsconfig.json create mode 100644 tests/test-app/wscript diff --git a/.gitignore b/.gitignore index e9a52f5..b2044f1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ example/src/pkjs/ node_modules/ .DS_Store .lock* -bin/ \ No newline at end of file +bin/ +ts-build/ +build/ \ No newline at end of file diff --git a/tests/test-app/README.md b/tests/test-app/README.md new file mode 100644 index 0000000..1f93f45 --- /dev/null +++ b/tests/test-app/README.md @@ -0,0 +1,36 @@ +# test-app + +A Pebble watchapp/watchface written in C using the Pebble SDK. + +## Building & running + +```sh +pebble build # build for all targetPlatforms +pebble install --emulator emery # install on the emery emulator +pebble install --phone # install to a paired phone +``` + +## Target platforms + +`targetPlatforms` in `package.json` controls which watches you build for. The +modern Pebble hardware is **emery** (Pebble Time 2), **gabbro** (Pebble Round +2), and **flint** (Pebble 2 Duo); the original Pebble platforms (aplite, +basalt, chalk, diorite) are included by default for backwards compatibility. + +## Project layout + +``` +src/c/ C source for the watchapp +src/pkjs/ PebbleKit JS (phone-side) source, if any +worker_src/c/ Background worker source, if any +resources/ Images, fonts, and other bundled resources +package.json Project metadata (UUID, platforms, resources, message keys) +wscript Build rules — usually no need to edit +``` + +By default this project is configured as a watchapp. To make it a watchface, +set `pebble.watchapp.watchface` to `true` in `package.json`. + +## Documentation + +Full SDK docs, tutorials, and API reference: diff --git a/tests/test-app/package-lock.json b/tests/test-app/package-lock.json new file mode 100644 index 0000000..f53957e --- /dev/null +++ b/tests/test-app/package-lock.json @@ -0,0 +1,52 @@ +{ + "name": "test-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "test-app", + "version": "1.0.0", + "devDependencies": { + "pkts": "file:../.." + } + }, + "../..": { + "name": "pkts", + "version": "0.3.0-beta.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/cli": "^7.28.3", + "@babel/core": "^7.28.5", + "@babel/plugin-transform-runtime": "^7.28.5", + "@babel/preset-env": "^7.28.0", + "@babel/preset-typescript": "^7.28.5", + "@babel/runtime": "^7.28.4", + "@babel/runtime-corejs3": "^7.28.4", + "@rollup/plugin-babel": "^6.1.0", + "@rollup/plugin-commonjs": "^28.0.6", + "@rollup/plugin-inject": "^5.0.5", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-terser": "^0.4.4", + "@rollup/plugin-typescript": "^12.3.0", + "@types/node": "^24.3.0", + "core-js": "^3.44.0", + "regenerator-runtime": "^0.14.1", + "rollup": "^4.53.3", + "termost": "^1.9.0", + "tslib": "^2.8.1", + "typescript": "^5.9.3", + "whatwg-fetch": "^3.6.20" + }, + "bin": { + "pkts": "bin/pkts.js" + } + }, + "node_modules/pkts": { + "resolved": "../..", + "link": true + } + } +} diff --git a/tests/test-app/package.json b/tests/test-app/package.json new file mode 100644 index 0000000..a43db16 --- /dev/null +++ b/tests/test-app/package.json @@ -0,0 +1,40 @@ +{ + "name": "test-app", + "author": "MakeAwesomeHappen", + "version": "1.0.0", + "keywords": ["pebble-app"], + "private": true, + "dependencies": {}, + "devDependencies": { + "pkts": "file:../.." + }, + "scripts": { + "build": "pkts build" + }, + "pebble": { + "displayName": "PKTS Test", + "uuid": "0eb49fc7-d058-474d-b0af-9d15e299704d", + "sdkVersion": "3", + "enableMultiJS": true, + "targetPlatforms": [ + "aplite", + "basalt", + "chalk", + "diorite", + "emery", + "flint", + "gabbro" + ], + "watchapp": { + "watchface": false + }, + "messageKeys": [ + "run_test", + "result", + "status" + ], + "resources": { + "media": [] + } + } +} diff --git a/tests/test-app/src/c/main.c b/tests/test-app/src/c/main.c new file mode 100644 index 0000000..6ddada5 --- /dev/null +++ b/tests/test-app/src/c/main.c @@ -0,0 +1,39 @@ +#include + +#include "runner.h" +#include "windows/main_menu.h" + +static Window *s_main_window; + +static void inbox_received(DictionaryIterator *iter, void *context) { + runner_on_inbox(iter); +} + +static void inbox_dropped(AppMessageResult reason, void *context) { + APP_LOG(APP_LOG_LEVEL_ERROR, "inbox dropped: %d", reason); +} + +static void outbox_failed(DictionaryIterator *iter, AppMessageResult reason, + void *context) { + runner_on_outbox_failed(reason); +} + +static void init(void) { + s_main_window = main_menu_create(); + window_stack_push(s_main_window, true); + + app_message_register_inbox_received(inbox_received); + app_message_register_inbox_dropped(inbox_dropped); + app_message_register_outbox_failed(outbox_failed); + app_message_open(512, 256); +} + +static void deinit(void) { + main_menu_destroy(s_main_window); +} + +int main(void) { + init(); + app_event_loop(); + deinit(); +} diff --git a/tests/test-app/src/c/runner.c b/tests/test-app/src/c/runner.c new file mode 100644 index 0000000..bd66377 --- /dev/null +++ b/tests/test-app/src/c/runner.c @@ -0,0 +1,91 @@ +#include "runner.h" + +static ReportRow s_rows[MAX_TOTAL_TESTS]; +static int s_row_count = 0; +static int s_current_row = -1; +static RunnerObserver s_observer = NULL; + +static void send_run_test(const char *name) { + DictionaryIterator *iter; + AppMessageResult res = app_message_outbox_begin(&iter); + if (res != APP_MSG_OK) { + APP_LOG(APP_LOG_LEVEL_ERROR, "outbox_begin failed: %d", res); + return; + } + dict_write_cstring(iter, MESSAGE_KEY_run_test, name); + app_message_outbox_send(); +} + +static void start_next_test(void) { + s_current_row++; + if (s_current_row >= s_row_count) { + int finished = s_current_row - 1; + s_current_row = -1; + APP_LOG(APP_LOG_LEVEL_INFO, "Report complete"); + if (s_observer) s_observer(finished, true); + return; + } + s_rows[s_current_row].status = ROW_RUNNING; + if (s_observer) s_observer(s_current_row, false); + send_run_test(s_rows[s_current_row].name); +} + +void runner_start(const Test *tests, int count, RunnerObserver observer) { + if (count > MAX_TOTAL_TESTS) count = MAX_TOTAL_TESTS; + s_row_count = count; + for (int i = 0; i < count; i++) { + s_rows[i].name = tests[i].name; + s_rows[i].status = ROW_PENDING; + s_rows[i].body[0] = 0; + } + s_current_row = -1; + s_observer = observer; + start_next_test(); +} + +void runner_cancel(void) { + s_current_row = -1; + s_observer = NULL; +} + +int runner_row_count(void) { return s_row_count; } + +const ReportRow *runner_row(int idx) { + if (idx < 0 || idx >= s_row_count) return NULL; + return &s_rows[idx]; +} + +void runner_on_inbox(DictionaryIterator *iter) { + if (s_current_row < 0 || s_current_row >= s_row_count) return; + + Tuple *status_t = dict_find(iter, MESSAGE_KEY_status); + Tuple *result_t = dict_find(iter, MESSAGE_KEY_result); + + s_rows[s_current_row].status = + (status_t && status_t->value->int32) ? ROW_PASS : ROW_FAIL; + if (result_t) { + snprintf(s_rows[s_current_row].body, sizeof(s_rows[s_current_row].body), + "%s", result_t->value->cstring); + } + + APP_LOG(APP_LOG_LEVEL_INFO, "Row %d %s: %s", s_current_row, + s_rows[s_current_row].status == ROW_PASS ? "PASS" : "FAIL", + s_rows[s_current_row].body); + + int finished_row = s_current_row; + if (s_observer) s_observer(finished_row, false); + + start_next_test(); +} + +void runner_on_outbox_failed(AppMessageResult reason) { + APP_LOG(APP_LOG_LEVEL_ERROR, "outbox failed: %d", reason); + if (s_current_row >= 0 && s_current_row < s_row_count) { + s_rows[s_current_row].status = ROW_FAIL; + snprintf(s_rows[s_current_row].body, sizeof(s_rows[s_current_row].body), + "outbox failed: %d", reason); + int finished_row = s_current_row; + if (s_observer) s_observer(finished_row, false); + start_next_test(); + } +} diff --git a/tests/test-app/src/c/runner.h b/tests/test-app/src/c/runner.h new file mode 100644 index 0000000..8974a19 --- /dev/null +++ b/tests/test-app/src/c/runner.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include "tests.h" + +#define MAX_TOTAL_TESTS 16 + +typedef enum { + ROW_PENDING = -1, + ROW_FAIL = 0, + ROW_PASS = 1, + ROW_RUNNING = 2, +} RowStatus; + +typedef struct { + const char *name; + RowStatus status; + char body[224]; +} ReportRow; + +// Called whenever a row's status or body changes. `finished_row` is the row +// that just transitioned (or -1 if none); `done` is true once the whole report +// has finished running. +typedef void (*RunnerObserver)(int finished_row, bool done); + +// Initialize a new report run. Copies the test list, resets state, starts the +// first test. `observer` may be NULL. +void runner_start(const Test *tests, int count, RunnerObserver observer); + +// Stop advancing into a destroyed report window. Idempotent. +void runner_cancel(void); + +int runner_row_count(void); +const ReportRow *runner_row(int idx); + +// Wire these into your AppMessage callbacks. +void runner_on_inbox(DictionaryIterator *iter); +void runner_on_outbox_failed(AppMessageResult reason); diff --git a/tests/test-app/src/c/tests.h b/tests/test-app/src/c/tests.h new file mode 100644 index 0000000..6c27d94 --- /dev/null +++ b/tests/test-app/src/c/tests.h @@ -0,0 +1,19 @@ +#pragma once + +typedef struct { + const char *name; +} Test; + +typedef struct { + const char *name; + const Test *tests; + int test_count; +} Category; + +// Registered categories, in display order. Defined in tests/registry.c. +extern const Category *const s_categories[]; +extern const int NUM_CATEGORIES; + +// Fills `out` with every test across every category, in registration order. +// Returns the number of tests written (capped at `max`). +int tests_flatten_all(Test *out, int max); diff --git a/tests/test-app/src/c/tests/appmessage.c b/tests/test-app/src/c/tests/appmessage.c new file mode 100644 index 0000000..31f0427 --- /dev/null +++ b/tests/test-app/src/c/tests/appmessage.c @@ -0,0 +1,11 @@ +#include "../tests.h" + +static const Test s_tests[] = { + {"sendAppMessage"}, +}; + +const Category category_appmessage = { + .name = "app message", + .tests = s_tests, + .test_count = sizeof(s_tests) / sizeof(s_tests[0]), +}; diff --git a/tests/test-app/src/c/tests/fetch.c b/tests/test-app/src/c/tests/fetch.c new file mode 100644 index 0000000..4250874 --- /dev/null +++ b/tests/test-app/src/c/tests/fetch.c @@ -0,0 +1,14 @@ +#include "../tests.h" + +static const Test s_tests[] = { + {"native fetch"}, + {"fetchJSON"}, + {"fetchString"}, + {"fetchBinary"}, +}; + +const Category category_fetch = { + .name = "fetch", + .tests = s_tests, + .test_count = sizeof(s_tests) / sizeof(s_tests[0]), +}; diff --git a/tests/test-app/src/c/tests/registry.c b/tests/test-app/src/c/tests/registry.c new file mode 100644 index 0000000..257e2d6 --- /dev/null +++ b/tests/test-app/src/c/tests/registry.c @@ -0,0 +1,24 @@ +#include "../tests.h" + +extern const Category category_appmessage; +extern const Category category_fetch; +extern const Category category_timeline; + +const Category *const s_categories[] = { + &category_appmessage, + &category_fetch, + &category_timeline, +}; +const int NUM_CATEGORIES = + sizeof(s_categories) / sizeof(s_categories[0]); + +int tests_flatten_all(Test *out, int max) { + int idx = 0; + for (int c = 0; c < NUM_CATEGORIES; c++) { + const Category *cat = s_categories[c]; + for (int t = 0; t < cat->test_count && idx < max; t++) { + out[idx++] = cat->tests[t]; + } + } + return idx; +} diff --git a/tests/test-app/src/c/tests/timeline.c b/tests/test-app/src/c/tests/timeline.c new file mode 100644 index 0000000..d4a6a17 --- /dev/null +++ b/tests/test-app/src/c/tests/timeline.c @@ -0,0 +1,13 @@ +#include "../tests.h" + +static const Test s_tests[] = { + {"getTimelineToken"}, + {"insertTimelinePin"}, + {"deleteTimelinePin"}, +}; + +const Category category_timeline = { + .name = "timeline", + .tests = s_tests, + .test_count = sizeof(s_tests) / sizeof(s_tests[0]), +}; diff --git a/tests/test-app/src/c/ui.h b/tests/test-app/src/c/ui.h new file mode 100644 index 0000000..ab53119 --- /dev/null +++ b/tests/test-app/src/c/ui.h @@ -0,0 +1,5 @@ +#pragma once + +#define ROW_H 36 +#define TITLE_H 22 +#define MENU_ROW_H 28 diff --git a/tests/test-app/src/c/windows/category_menu.c b/tests/test-app/src/c/windows/category_menu.c new file mode 100644 index 0000000..5e14828 --- /dev/null +++ b/tests/test-app/src/c/windows/category_menu.c @@ -0,0 +1,67 @@ +#include + +#include "category_menu.h" +#include "../ui.h" +#include "report.h" + +static Window *s_window; +static MenuLayer *s_menu; +static const Category *s_category; + +static uint16_t get_num_rows(MenuLayer *ml, uint16_t section, void *ctx) { + return s_category ? s_category->test_count + 1 : 0; +} + +static int16_t get_cell_height(MenuLayer *ml, MenuIndex *idx, void *ctx) { + return MENU_ROW_H; +} + +static void draw_row(GContext *g, const Layer *cell, MenuIndex *idx, + void *ctx) { + if (idx->row == 0) { + menu_cell_basic_draw(g, cell, "TEST ALL", NULL, NULL); + } else { + menu_cell_basic_draw(g, cell, s_category->tests[idx->row - 1].name, NULL, + NULL); + } +} + +static void select_row(MenuLayer *ml, MenuIndex *idx, void *ctx) { + if (!s_category) return; + if (idx->row == 0) { + report_show(s_category->name, s_category->tests, s_category->test_count); + } else { + report_show(s_category->name, &s_category->tests[idx->row - 1], 1); + } +} + +static void window_load(Window *window) { + Layer *root = window_get_root_layer(window); + GRect b = layer_get_bounds(root); + s_menu = menu_layer_create(b); + menu_layer_set_callbacks(s_menu, NULL, (MenuLayerCallbacks){ + .get_num_rows = get_num_rows, + .draw_row = draw_row, + .get_cell_height = get_cell_height, + .select_click = select_row, + }); + menu_layer_set_click_config_onto_window(s_menu, window); + layer_add_child(root, menu_layer_get_layer(s_menu)); +} + +static void window_unload(Window *window) { + menu_layer_destroy(s_menu); + s_menu = NULL; + s_window = NULL; + s_category = NULL; +} + +void category_menu_push(const Category *category) { + s_category = category; + s_window = window_create(); + window_set_window_handlers(s_window, (WindowHandlers){ + .load = window_load, + .unload = window_unload, + }); + window_stack_push(s_window, true); +} diff --git a/tests/test-app/src/c/windows/category_menu.h b/tests/test-app/src/c/windows/category_menu.h new file mode 100644 index 0000000..fcc4c7d --- /dev/null +++ b/tests/test-app/src/c/windows/category_menu.h @@ -0,0 +1,5 @@ +#pragma once + +#include "../tests.h" + +void category_menu_push(const Category *category); diff --git a/tests/test-app/src/c/windows/detail.c b/tests/test-app/src/c/windows/detail.c new file mode 100644 index 0000000..0cfb92e --- /dev/null +++ b/tests/test-app/src/c/windows/detail.c @@ -0,0 +1,74 @@ +#include + +#include "detail.h" +#include "../ui.h" + +static Window *s_window; +static ScrollLayer *s_scroll; +static TextLayer *s_title; +static TextLayer *s_body; +static char s_title_buf[48]; +static char s_body_buf[256]; + +static void window_load(Window *window) { + Layer *root = window_get_root_layer(window); + GRect b = layer_get_bounds(root); + + s_title = text_layer_create(GRect(0, 0, b.size.w, TITLE_H)); + text_layer_set_text(s_title, s_title_buf); + text_layer_set_text_alignment(s_title, GTextAlignmentCenter); + text_layer_set_font(s_title, fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD)); + text_layer_set_background_color(s_title, GColorBlack); + text_layer_set_text_color(s_title, GColorWhite); + layer_add_child(root, text_layer_get_layer(s_title)); + + GRect scroll_frame = GRect(0, TITLE_H, b.size.w, b.size.h - TITLE_H); + s_scroll = scroll_layer_create(scroll_frame); + scroll_layer_set_click_config_onto_window(s_scroll, window); + + GRect body_frame = GRect(4, 4, scroll_frame.size.w - 8, 2000); + s_body = text_layer_create(body_frame); + text_layer_set_text(s_body, s_body_buf); + text_layer_set_font(s_body, fonts_get_system_font(FONT_KEY_GOTHIC_18)); + text_layer_set_overflow_mode(s_body, GTextOverflowModeWordWrap); + text_layer_set_background_color(s_body, GColorClear); + + GSize content = text_layer_get_content_size(s_body); + content.h += 8; + if (content.h < scroll_frame.size.h) content.h = scroll_frame.size.h; + layer_set_frame(text_layer_get_layer(s_body), + GRect(4, 4, scroll_frame.size.w - 8, content.h)); + scroll_layer_set_content_size(s_scroll, + GSize(scroll_frame.size.w, content.h + 8)); + + scroll_layer_add_child(s_scroll, text_layer_get_layer(s_body)); + layer_add_child(root, scroll_layer_get_layer(s_scroll)); +} + +static void window_unload(Window *window) { + text_layer_destroy(s_title); + text_layer_destroy(s_body); + scroll_layer_destroy(s_scroll); + s_title = NULL; + s_body = NULL; + s_scroll = NULL; + s_window = NULL; +} + +void detail_push(const ReportRow *row) { + if (!row) return; + const char *prefix = "..."; + if (row->status == ROW_PASS) prefix = "PASS"; + else if (row->status == ROW_FAIL) prefix = "FAIL"; + else if (row->status == ROW_PENDING) prefix = "—"; + snprintf(s_title_buf, sizeof(s_title_buf), "%s: %s", prefix, row->name); + snprintf(s_body_buf, sizeof(s_body_buf), "%s", + row->body[0] ? row->body : "(no result yet)"); + + s_window = window_create(); + window_set_window_handlers(s_window, (WindowHandlers){ + .load = window_load, + .unload = window_unload, + }); + window_stack_push(s_window, true); +} diff --git a/tests/test-app/src/c/windows/detail.h b/tests/test-app/src/c/windows/detail.h new file mode 100644 index 0000000..f653888 --- /dev/null +++ b/tests/test-app/src/c/windows/detail.h @@ -0,0 +1,5 @@ +#pragma once + +#include "../runner.h" + +void detail_push(const ReportRow *row); diff --git a/tests/test-app/src/c/windows/main_menu.c b/tests/test-app/src/c/windows/main_menu.c new file mode 100644 index 0000000..30e8f42 --- /dev/null +++ b/tests/test-app/src/c/windows/main_menu.c @@ -0,0 +1,68 @@ +#include + +#include "main_menu.h" +#include "../runner.h" +#include "../tests.h" +#include "../ui.h" +#include "category_menu.h" +#include "report.h" + +static MenuLayer *s_menu; +static Test s_flat_tests[MAX_TOTAL_TESTS]; + +static uint16_t get_num_rows(MenuLayer *ml, uint16_t section, void *ctx) { + return 1 + NUM_CATEGORIES; +} + +static int16_t get_cell_height(MenuLayer *ml, MenuIndex *idx, void *ctx) { + return MENU_ROW_H; +} + +static void draw_row(GContext *g, const Layer *cell, MenuIndex *idx, + void *ctx) { + if (idx->row == 0) { + menu_cell_basic_draw(g, cell, "TEST ALL", NULL, NULL); + } else { + int cat_idx = idx->row - 1; + menu_cell_basic_draw(g, cell, s_categories[cat_idx]->name, NULL, NULL); + } +} + +static void select_row(MenuLayer *ml, MenuIndex *idx, void *ctx) { + if (idx->row == 0) { + int count = tests_flatten_all(s_flat_tests, MAX_TOTAL_TESTS); + report_show("All tests", s_flat_tests, count); + } else { + category_menu_push(s_categories[idx->row - 1]); + } +} + +static void window_load(Window *window) { + Layer *root = window_get_root_layer(window); + GRect b = layer_get_bounds(root); + s_menu = menu_layer_create(b); + menu_layer_set_callbacks(s_menu, NULL, (MenuLayerCallbacks){ + .get_num_rows = get_num_rows, + .draw_row = draw_row, + .get_cell_height = get_cell_height, + .select_click = select_row, + }); + menu_layer_set_click_config_onto_window(s_menu, window); + layer_add_child(root, menu_layer_get_layer(s_menu)); +} + +static void window_unload(Window *window) { + menu_layer_destroy(s_menu); + s_menu = NULL; +} + +Window *main_menu_create(void) { + Window *window = window_create(); + window_set_window_handlers(window, (WindowHandlers){ + .load = window_load, + .unload = window_unload, + }); + return window; +} + +void main_menu_destroy(Window *window) { window_destroy(window); } diff --git a/tests/test-app/src/c/windows/main_menu.h b/tests/test-app/src/c/windows/main_menu.h new file mode 100644 index 0000000..b6bd941 --- /dev/null +++ b/tests/test-app/src/c/windows/main_menu.h @@ -0,0 +1,6 @@ +#pragma once + +#include + +Window *main_menu_create(void); +void main_menu_destroy(Window *window); diff --git a/tests/test-app/src/c/windows/report.c b/tests/test-app/src/c/windows/report.c new file mode 100644 index 0000000..e6be995 --- /dev/null +++ b/tests/test-app/src/c/windows/report.c @@ -0,0 +1,141 @@ +#include + +#include "report.h" +#include "../runner.h" +#include "../ui.h" +#include "detail.h" + +static Window *s_window; +static TextLayer *s_title_layer; +static MenuLayer *s_menu; +static char s_title[24]; +static Test s_pending_tests[MAX_TOTAL_TESTS]; +static int s_pending_count; + +static uint16_t get_num_rows(MenuLayer *ml, uint16_t section, void *ctx) { + return runner_row_count(); +} + +static int16_t get_cell_height(MenuLayer *ml, MenuIndex *idx, void *ctx) { + return ROW_H; +} + +static void draw_row(GContext *g, const Layer *cell, MenuIndex *idx, + void *ctx) { + const ReportRow *row = runner_row(idx->row); + if (!row) return; + + GRect b = layer_get_bounds(cell); + GFont name_font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD); + GFont body_font = fonts_get_system_font(FONT_KEY_GOTHIC_14); + + GColor bar_color; + const char *status_text; + switch (row->status) { + case ROW_PASS: + bar_color = PBL_IF_COLOR_ELSE(GColorIslamicGreen, GColorBlack); + status_text = "PASS"; + break; + case ROW_FAIL: + bar_color = PBL_IF_COLOR_ELSE(GColorRed, GColorBlack); + status_text = "FAIL"; + break; + case ROW_RUNNING: + bar_color = PBL_IF_COLOR_ELSE(GColorChromeYellow, GColorDarkGray); + status_text = "..."; + break; + default: + bar_color = PBL_IF_COLOR_ELSE(GColorLightGray, GColorWhite); + status_text = ""; + break; + } + + bool highlighted = menu_cell_layer_is_highlighted(cell); + GColor text_color = highlighted ? GColorWhite : GColorBlack; + + graphics_context_set_fill_color(g, bar_color); + graphics_fill_rect(g, GRect(2, 3, 8, b.size.h - 6), 2, GCornersAll); + + graphics_context_set_text_color(g, text_color); + graphics_draw_text(g, row->name, name_font, + GRect(14, -2, b.size.w - 60, 22), + GTextOverflowModeTrailingEllipsis, GTextAlignmentLeft, + NULL); + graphics_draw_text(g, status_text, body_font, + GRect(b.size.w - 46, 0, 44, 18), + GTextOverflowModeTrailingEllipsis, GTextAlignmentRight, + NULL); + if (row->body[0]) { + graphics_draw_text(g, row->body, body_font, + GRect(14, 16, b.size.w - 18, b.size.h - 16), + GTextOverflowModeTrailingEllipsis, GTextAlignmentLeft, + NULL); + } +} + +static void select_row(MenuLayer *ml, MenuIndex *idx, void *ctx) { + detail_push(runner_row(idx->row)); +} + +static void on_runner_change(int finished_row, bool done) { + if (!s_menu) return; + menu_layer_reload_data(s_menu); + + // Keep the running (or last-finished) row visible while the report runs. + int target = finished_row + 1; + if (done || target >= runner_row_count()) target = finished_row; + if (target < 0) return; + menu_layer_set_selected_index( + s_menu, (MenuIndex){.section = 0, .row = target}, + MenuRowAlignCenter, true); +} + +static void window_load(Window *window) { + Layer *root = window_get_root_layer(window); + GRect b = layer_get_bounds(root); + + s_title_layer = text_layer_create(GRect(0, 0, b.size.w, TITLE_H)); + text_layer_set_text(s_title_layer, s_title); + text_layer_set_text_alignment(s_title_layer, GTextAlignmentCenter); + text_layer_set_font(s_title_layer, + fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD)); + text_layer_set_background_color(s_title_layer, GColorBlack); + text_layer_set_text_color(s_title_layer, GColorWhite); + layer_add_child(root, text_layer_get_layer(s_title_layer)); + + GRect menu_frame = GRect(0, TITLE_H, b.size.w, b.size.h - TITLE_H); + s_menu = menu_layer_create(menu_frame); + menu_layer_set_callbacks(s_menu, NULL, (MenuLayerCallbacks){ + .get_num_rows = get_num_rows, + .draw_row = draw_row, + .get_cell_height = get_cell_height, + .select_click = select_row, + }); + menu_layer_set_click_config_onto_window(s_menu, window); + layer_add_child(root, menu_layer_get_layer(s_menu)); + + runner_start(s_pending_tests, s_pending_count, on_runner_change); +} + +static void window_unload(Window *window) { + runner_cancel(); + menu_layer_destroy(s_menu); + text_layer_destroy(s_title_layer); + s_menu = NULL; + s_title_layer = NULL; + s_window = NULL; +} + +void report_show(const char *title, const Test *tests, int count) { + if (count > MAX_TOTAL_TESTS) count = MAX_TOTAL_TESTS; + for (int i = 0; i < count; i++) s_pending_tests[i] = tests[i]; + s_pending_count = count; + snprintf(s_title, sizeof(s_title), "%s", title); + + s_window = window_create(); + window_set_window_handlers(s_window, (WindowHandlers){ + .load = window_load, + .unload = window_unload, + }); + window_stack_push(s_window, true); +} diff --git a/tests/test-app/src/c/windows/report.h b/tests/test-app/src/c/windows/report.h new file mode 100644 index 0000000..7d89278 --- /dev/null +++ b/tests/test-app/src/c/windows/report.h @@ -0,0 +1,6 @@ +#pragma once + +#include "../tests.h" + +// Push the report window and start running the given tests. +void report_show(const char *title, const Test *tests, int count); diff --git a/tests/test-app/src/ts/index.ts b/tests/test-app/src/ts/index.ts new file mode 100644 index 0000000..2aff383 --- /dev/null +++ b/tests/test-app/src/ts/index.ts @@ -0,0 +1,36 @@ +// PKTS test harness. The C side sends `run_test` with a test name string; we +// look up the matching runner and reply with `status` (1=pass, 0=fail) and a +// `result` string. + +import { reply } from "./reply"; +import { TESTS } from "./tests"; + +Pebble.addEventListener("ready", () => { + console.log("PKTS test harness ready"); + const info = Pebble.getActiveWatchInfo(); + console.log(`Watch: ${JSON.stringify(info)}`); + console.log(`Account token: ${Pebble.getAccountToken()}`); + console.log(`Watch token: ${Pebble.getWatchToken()}`); +}); + +Pebble.addEventListener("appmessage", async (e) => { + const payload = e.payload as { run_test?: string }; + const name = payload.run_test; + if (typeof name !== "string") return; + + console.log(`Running test: ${name}`); + + const runner = TESTS[name]; + if (!runner) { + await reply(false, `unknown test "${name}"`); + return; + } + + try { + await runner(); + } catch (err: any) { + const msg = err?.message ?? String(err); + console.log(`Test ${name} threw: ${msg}`); + await reply(false, `threw: ${msg}`); + } +}); diff --git a/tests/test-app/src/ts/reply.ts b/tests/test-app/src/ts/reply.ts new file mode 100644 index 0000000..6624810 --- /dev/null +++ b/tests/test-app/src/ts/reply.ts @@ -0,0 +1,9 @@ +export async function reply(pass: boolean, result: string): Promise { + const trimmed = result.length > 200 ? result.slice(0, 197) + "..." : result; + console.log(`[test result] ${pass ? "PASS" : "FAIL"}: ${trimmed}`); + try { + await PebbleTS.sendAppMessage({ status: pass ? 1 : 0, result: trimmed }); + } catch (err) { + console.log(`Failed to send reply: ${JSON.stringify(err)}`); + } +} diff --git a/tests/test-app/src/ts/tests/deleteTimelinePin.ts b/tests/test-app/src/ts/tests/deleteTimelinePin.ts new file mode 100644 index 0000000..0dc37c2 --- /dev/null +++ b/tests/test-app/src/ts/tests/deleteTimelinePin.ts @@ -0,0 +1,10 @@ +import { reply } from "../reply"; +import { PIN_ID, getLastInsertedPinId } from "./timelinePinState"; + +export const name = "deleteTimelinePin"; + +export async function run(): Promise { + const id = getLastInsertedPinId() ?? PIN_ID; + PebbleTS.deleteTimelinePin(id); + await reply(true, `deleted id=${id}`); +} diff --git a/tests/test-app/src/ts/tests/fetchBinary.ts b/tests/test-app/src/ts/tests/fetchBinary.ts new file mode 100644 index 0000000..726f198 --- /dev/null +++ b/tests/test-app/src/ts/tests/fetchBinary.ts @@ -0,0 +1,11 @@ +import { reply } from "../reply"; + +export const name = "fetchBinary"; + +export async function run(): Promise { + const bytes = await PebbleTS.fetchBinary( + "https://jsonplaceholder.typicode.com/posts/4" + ); + const ok = bytes instanceof Uint8Array && bytes.length > 0; + await reply(ok, `bytes=${bytes.length}`); +} diff --git a/tests/test-app/src/ts/tests/fetchJSON.ts b/tests/test-app/src/ts/tests/fetchJSON.ts new file mode 100644 index 0000000..87d44f1 --- /dev/null +++ b/tests/test-app/src/ts/tests/fetchJSON.ts @@ -0,0 +1,11 @@ +import { reply } from "../reply"; + +export const name = "fetchJSON"; + +export async function run(): Promise { + const data = await PebbleTS.fetchJSON<{ id: number; title: string }>( + "https://jsonplaceholder.typicode.com/posts/1" + ); + const ok = data && data.id === 1 && typeof data.title === "string"; + await reply(!!ok, `id=${data?.id} title="${data?.title}"`); +} diff --git a/tests/test-app/src/ts/tests/fetchString.ts b/tests/test-app/src/ts/tests/fetchString.ts new file mode 100644 index 0000000..0bab15f --- /dev/null +++ b/tests/test-app/src/ts/tests/fetchString.ts @@ -0,0 +1,11 @@ +import { reply } from "../reply"; + +export const name = "fetchString"; + +export async function run(): Promise { + const str = await PebbleTS.fetchString( + "https://jsonplaceholder.typicode.com/posts/3" + ); + const ok = typeof str === "string" && str.includes('"id"'); + await reply(ok, `len=${str.length}`); +} diff --git a/tests/test-app/src/ts/tests/getTimelineToken.ts b/tests/test-app/src/ts/tests/getTimelineToken.ts new file mode 100644 index 0000000..a403b19 --- /dev/null +++ b/tests/test-app/src/ts/tests/getTimelineToken.ts @@ -0,0 +1,9 @@ +import { reply } from "../reply"; + +export const name = "getTimelineToken"; + +export async function run(): Promise { + const token = await PebbleTS.getTimelineToken(); + const ok = typeof token === "string" && token.length > 0; + await reply(ok, `token=${token.slice(0, 12)}...`); +} diff --git a/tests/test-app/src/ts/tests/index.ts b/tests/test-app/src/ts/tests/index.ts new file mode 100644 index 0000000..e862c9e --- /dev/null +++ b/tests/test-app/src/ts/tests/index.ts @@ -0,0 +1,28 @@ +import * as sendAppMessage from "./sendAppMessage"; +import * as fetchJSON from "./fetchJSON"; +import * as nativeFetch from "./nativeFetch"; +import * as fetchString from "./fetchString"; +import * as fetchBinary from "./fetchBinary"; +import * as getTimelineToken from "./getTimelineToken"; +import * as insertTimelinePin from "./insertTimelinePin"; +import * as deleteTimelinePin from "./deleteTimelinePin"; + +export interface Test { + name: string; + run: () => Promise; +} + +const allTests: Test[] = [ + sendAppMessage, + fetchJSON, + nativeFetch, + fetchString, + fetchBinary, + getTimelineToken, + insertTimelinePin, + deleteTimelinePin, +]; + +export const TESTS: Record Promise> = Object.fromEntries( + allTests.map((t) => [t.name, t.run]) +); diff --git a/tests/test-app/src/ts/tests/insertTimelinePin.ts b/tests/test-app/src/ts/tests/insertTimelinePin.ts new file mode 100644 index 0000000..4d6af45 --- /dev/null +++ b/tests/test-app/src/ts/tests/insertTimelinePin.ts @@ -0,0 +1,22 @@ +import { reply } from "../reply"; +import { PIN_ID, setLastInsertedPinId } from "./timelinePinState"; + +export const name = "insertTimelinePin"; + +export async function run(): Promise { + const pin: TimelinePin = { + id: PIN_ID, + time: new Date(Date.now() + 60 * 60 * 1000), + duration: 10, + layout: { + type: "genericPin", + title: "PKTS test pin", + tinyIcon: "system://images/NOTIFICATION_FLAG", + subtitle: "Inserted by test app", + body: "If you can see this in the timeline, insertTimelinePin works.", + }, + }; + PebbleTS.insertTimelinePin(pin); + setLastInsertedPinId(PIN_ID); + await reply(true, `inserted id=${PIN_ID}`); +} diff --git a/tests/test-app/src/ts/tests/nativeFetch.ts b/tests/test-app/src/ts/tests/nativeFetch.ts new file mode 100644 index 0000000..f978bc0 --- /dev/null +++ b/tests/test-app/src/ts/tests/nativeFetch.ts @@ -0,0 +1,11 @@ +import { reply } from "../reply"; + +export const name = "native fetch"; + +export async function run(): Promise { + const res = await fetch("https://jsonplaceholder.typicode.com/posts/2"); + const text = await res.text(); + const parsed = JSON.parse(text); + const ok = res.ok && parsed.id === 2; + await reply(ok, `status=${res.status} id=${parsed.id}`); +} diff --git a/tests/test-app/src/ts/tests/sendAppMessage.ts b/tests/test-app/src/ts/tests/sendAppMessage.ts new file mode 100644 index 0000000..fc2aaa1 --- /dev/null +++ b/tests/test-app/src/ts/tests/sendAppMessage.ts @@ -0,0 +1,7 @@ +import { reply } from "../reply"; + +export const name = "sendAppMessage"; + +export async function run(): Promise { + await reply(true, "round-trip ok"); +} diff --git a/tests/test-app/src/ts/tests/timelinePinState.ts b/tests/test-app/src/ts/tests/timelinePinState.ts new file mode 100644 index 0000000..28c1427 --- /dev/null +++ b/tests/test-app/src/ts/tests/timelinePinState.ts @@ -0,0 +1,11 @@ +export const PIN_ID = "pkts-test-pin-1"; + +let lastInsertedPinId: string | null = null; + +export function setLastInsertedPinId(id: string): void { + lastInsertedPinId = id; +} + +export function getLastInsertedPinId(): string | null { + return lastInsertedPinId; +} diff --git a/tests/test-app/tsconfig.json b/tests/test-app/tsconfig.json new file mode 100644 index 0000000..e0b3824 --- /dev/null +++ b/tests/test-app/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "pkts/config/tsconfig.json", + "include": ["src/ts/**/*", "node_modules/pkts/lib/**/*"] +} diff --git a/tests/test-app/wscript b/tests/test-app/wscript new file mode 100644 index 0000000..56edda5 --- /dev/null +++ b/tests/test-app/wscript @@ -0,0 +1,56 @@ +# +# This file is the default set of rules to compile a Pebble application. +# +# Feel free to customize this to your needs. +# +import os.path + +top = '.' +out = 'build' + + +def options(ctx): + ctx.load('pebble_sdk') + + +def configure(ctx): + """ + This method is used to configure your build. ctx.load(`pebble_sdk`) automatically configures + a build for each valid platform in `targetPlatforms`. Platform-specific configuration: add your + change after calling ctx.load('pebble_sdk') and make sure to set the correct environment first. + Universal configuration: add your change prior to calling ctx.load('pebble_sdk'). + """ + ctx.load('pebble_sdk') + + +def build(ctx): + ctx.exec_command('npm run build') + + ctx.load('pebble_sdk') + + build_worker = os.path.exists('worker_src') + binaries = [] + + cached_env = ctx.env + for platform in ctx.env.TARGET_PLATFORMS: + ctx.env = ctx.all_envs[platform] + ctx.set_group(ctx.env.PLATFORM_NAME) + app_elf = '{}/pebble-app.elf'.format(ctx.env.BUILD_DIR) + ctx.pbl_build(source=ctx.path.ant_glob('src/c/**/*.c'), target=app_elf, bin_type='app') + + if build_worker: + worker_elf = '{}/pebble-worker.elf'.format(ctx.env.BUILD_DIR) + binaries.append({'platform': platform, 'app_elf': app_elf, 'worker_elf': worker_elf}) + ctx.pbl_build(source=ctx.path.ant_glob('worker_src/c/**/*.c'), + target=worker_elf, + bin_type='worker') + else: + binaries.append({'platform': platform, 'app_elf': app_elf}) + ctx.env = cached_env + + ctx.set_group('bundle') + ctx.pbl_bundle(binaries=binaries, + js=ctx.path.ant_glob(['src/ts-build/**/*.js', + 'src/pkjs/**/*.json', + 'src/common/**/*.js']), + js_entry_file='src/ts-build/index.js') From 0f3ea48cdae288cca3bd09fb358e38b59c38988c Mon Sep 17 00:00:00 2001 From: Joe Citrine Date: Tue, 19 May 2026 18:41:56 +0100 Subject: [PATCH 2/7] Add test runner --- tests/test-app/package.json | 3 +- tests/test-app/run-tests.mjs | 187 +++++++++++++++++++++++++++++++++ tests/test-app/src/c/runner.c | 2 +- tests/test-app/src/ts/index.ts | 15 ++- tests/test-app/src/ts/reply.ts | 2 +- 5 files changed, 202 insertions(+), 7 deletions(-) create mode 100755 tests/test-app/run-tests.mjs diff --git a/tests/test-app/package.json b/tests/test-app/package.json index a43db16..06f3d25 100644 --- a/tests/test-app/package.json +++ b/tests/test-app/package.json @@ -9,7 +9,8 @@ "pkts": "file:../.." }, "scripts": { - "build": "pkts build" + "build": "pkts build", + "test": "node run-tests.mjs" }, "pebble": { "displayName": "PKTS Test", diff --git a/tests/test-app/run-tests.mjs b/tests/test-app/run-tests.mjs new file mode 100755 index 0000000..46018fb --- /dev/null +++ b/tests/test-app/run-tests.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const PLATFORM = "emery"; +const HERE = dirname(fileURLToPath(import.meta.url)); +const OVERALL_TIMEOUT_MS = 5 * 60 * 1000; + +const RED = "\x1b[31m"; +const GREEN = "\x1b[32m"; +const DIM = "\x1b[2m"; +const BOLD = "\x1b[1m"; +const RESET = "\x1b[0m"; + +function run(cmd, args, opts = {}) { + return new Promise((resolveP, rejectP) => { + const child = spawn(cmd, args, { stdio: "inherit", cwd: HERE, ...opts }); + child.on("error", rejectP); + child.on("exit", (code) => { + if (code === 0) resolveP(); + else rejectP(new Error(`${cmd} ${args.join(" ")} exited ${code}`)); + }); + }); +} + +async function main() { + console.log(`${BOLD}==> Kill and wipe emulator${RESET}`); + await run("pebble", ["kill"]); + await run("pebble", ["wipe"]); + + console.log(`${BOLD}==> Build test app${RESET}`); + await run("pebble", ["build"]); + + console.log(`${BOLD}==> Installing on ${PLATFORM}${RESET}`); + await run("pebble", ["install", "--emulator", PLATFORM]); + + console.log(`${BOLD}==> Tailing logs${RESET}`); + const logs = spawn("pebble", ["logs", "--emulator", PLATFORM], { + cwd: HERE, + stdio: ["ignore", "pipe", "pipe"], + }); + + /** @type {{name: string, status: "PASS"|"FAIL", body: string, output: string[]}[]} */ + const tests = []; + let current = null; + let reportDone = false; + + const overallTimeout = setTimeout(() => { + console.error( + `${RED}Overall timeout after ${OVERALL_TIMEOUT_MS}ms${RESET}`, + ); + finish(2); + }, OVERALL_TIMEOUT_MS); + + let exitCode = -1; + function finish(code) { + if (exitCode !== -1) return; + exitCode = code; + clearTimeout(overallTimeout); + try { + logs.kill("SIGTERM"); + } catch {} + // Backstop: if `pebble kill` hangs or errors, exit anyway after 5s. + const backstop = setTimeout(() => { + printSummary(); + process.exit(exitCode); + }, 5000).unref(); + const killer = spawn("pebble", ["kill"], { stdio: "ignore" }); + const done = () => { + clearTimeout(backstop); + printSummary(); + process.exit(exitCode); + }; + killer.on("exit", done); + killer.on("error", done); + } + + function printSummary() { + console.log(""); + console.log( + `${BOLD}==================== RESULTS ====================${RESET}`, + ); + for (const t of tests) { + const tag = + t.status === "PASS" + ? `${GREEN}PASS${RESET}` + : `${RED}${t.status}${RESET}`; + console.log( + `${tag} ${t.name}${t.body ? ` ${DIM}${t.body}${RESET}` : ""}`, + ); + if (t.status !== "PASS" && t.output.length) { + for (const line of t.output) console.log(` ${DIM}${line}${RESET}`); + } + } + const passes = tests.filter((t) => t.status === "PASS").length; + const fails = tests.length - passes; + console.log( + `${BOLD}=================================================${RESET}`, + ); + console.log( + `${BOLD}${passes} passed, ${fails} failed, ${tests.length} total${RESET}`, + ); + } + + // Pebble logs prefix lines with e.g. "[HH:MM:SS] pkjs> " or "[HH:MM:SS] runner.c:71> ". + function stripPrefix(line) { + const i = line.indexOf("> "); + return i === -1 ? line : line.slice(i + 2); + } + + function handleLine(rawLine) { + const line = rawLine.replace(/\r$/, ""); + if (!line) return; + const msg = stripPrefix(line); + + let m; + if ((m = msg.match(/\[TEST_START\]\s+(.*)$/))) { + current = { + name: m[1], + status: "FAIL", + body: "(no result received)", + output: [], + }; + tests.push(current); + return; + } + if ((m = msg.match(/\[TEST_RESULT\]\s+(PASS|FAIL):\s*(.*)$/))) { + if (current) { + current.status = m[1]; + current.body = m[2]; + } + return; + } + if (/\[TEST_END\]\s+/.test(msg)) { + current = null; + return; + } + if (msg.includes("[REPORT_DONE]")) { + reportDone = true; + finish(tests.some((t) => t.status !== "PASS") ? 1 : 0); + return; + } + + if (current) { + current.output.push(msg); + } + } + + let buf = ""; + logs.stdout.on("data", (chunk) => { + buf += chunk.toString(); + let i; + while ((i = buf.indexOf("\n")) !== -1) { + const line = buf.slice(0, i); + buf = buf.slice(i + 1); + // Mirror raw logs at low contrast so the user can see liveness. + process.stdout.write(`${DIM}${line}${RESET}\n`); + handleLine(line); + } + }); + logs.stderr.on("data", (chunk) => process.stderr.write(chunk)); + logs.on("exit", () => { + if (!reportDone) { + console.error(`${RED}pebble logs exited before [REPORT_DONE]${RESET}`); + finish(2); + } + }); + + // Give pkjs a moment to settle, then trigger TEST ALL (SELECT on row 0). + await new Promise((r) => setTimeout(r, 2000)); + console.log(`${BOLD}==> pressing SELECT to start TEST ALL${RESET}`); + await run("pebble", [ + "emu-button", + "--emulator", + PLATFORM, + "click", + "select", + ]); +} + +main().catch((err) => { + console.error(`${RED}${err.stack || err.message || err}${RESET}`); + spawn("pebble", ["kill"], { stdio: "ignore" }).on("exit", () => + process.exit(2), + ); +}); diff --git a/tests/test-app/src/c/runner.c b/tests/test-app/src/c/runner.c index bd66377..2f44b36 100644 --- a/tests/test-app/src/c/runner.c +++ b/tests/test-app/src/c/runner.c @@ -21,7 +21,7 @@ static void start_next_test(void) { if (s_current_row >= s_row_count) { int finished = s_current_row - 1; s_current_row = -1; - APP_LOG(APP_LOG_LEVEL_INFO, "Report complete"); + APP_LOG(APP_LOG_LEVEL_INFO, "[REPORT_DONE]"); if (s_observer) s_observer(finished, true); return; } diff --git a/tests/test-app/src/ts/index.ts b/tests/test-app/src/ts/index.ts index 2aff383..141a3a0 100644 --- a/tests/test-app/src/ts/index.ts +++ b/tests/test-app/src/ts/index.ts @@ -5,6 +5,8 @@ import { reply } from "./reply"; import { TESTS } from "./tests"; +let queue: Promise = Promise.resolve(); + Pebble.addEventListener("ready", () => { console.log("PKTS test harness ready"); const info = Pebble.getActiveWatchInfo(); @@ -13,16 +15,20 @@ Pebble.addEventListener("ready", () => { console.log(`Watch token: ${Pebble.getWatchToken()}`); }); -Pebble.addEventListener("appmessage", async (e) => { +Pebble.addEventListener("appmessage", (e) => { const payload = e.payload as { run_test?: string }; const name = payload.run_test; if (typeof name !== "string") return; + queue = queue.then(() => handleRunTest(name)); +}); - console.log(`Running test: ${name}`); +async function handleRunTest(name: string): Promise { + console.log(`[TEST_START] ${name}`); const runner = TESTS[name]; if (!runner) { await reply(false, `unknown test "${name}"`); + console.log(`[TEST_END] ${name}`); return; } @@ -30,7 +36,8 @@ Pebble.addEventListener("appmessage", async (e) => { await runner(); } catch (err: any) { const msg = err?.message ?? String(err); - console.log(`Test ${name} threw: ${msg}`); + console.log(`threw: ${msg}`); await reply(false, `threw: ${msg}`); } -}); + console.log(`[TEST_END] ${name}`); +} diff --git a/tests/test-app/src/ts/reply.ts b/tests/test-app/src/ts/reply.ts index 6624810..71db799 100644 --- a/tests/test-app/src/ts/reply.ts +++ b/tests/test-app/src/ts/reply.ts @@ -1,6 +1,6 @@ export async function reply(pass: boolean, result: string): Promise { const trimmed = result.length > 200 ? result.slice(0, 197) + "..." : result; - console.log(`[test result] ${pass ? "PASS" : "FAIL"}: ${trimmed}`); + console.log(`[TEST_RESULT] ${pass ? "PASS" : "FAIL"}: ${trimmed}`); try { await PebbleTS.sendAppMessage({ status: pass ? 1 : 0, result: trimmed }); } catch (err) { From 86cf95a4abb99c7435fd8e0de02e68672dea9ebf Mon Sep 17 00:00:00 2001 From: Joe Citrine Date: Tue, 19 May 2026 18:53:32 +0100 Subject: [PATCH 3/7] Add CI specific runner --- tests/test-app/src/c/tests.h | 3 +++ tests/test-app/src/c/tests/registry.c | 12 ++++++++++++ tests/test-app/src/c/windows/main_menu.c | 11 ++++++++--- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/tests/test-app/src/c/tests.h b/tests/test-app/src/c/tests.h index 6c27d94..f9ed5e6 100644 --- a/tests/test-app/src/c/tests.h +++ b/tests/test-app/src/c/tests.h @@ -17,3 +17,6 @@ extern const int NUM_CATEGORIES; // Fills `out` with every test across every category, in registration order. // Returns the number of tests written (capped at `max`). int tests_flatten_all(Test *out, int max); + +// Like tests_flatten_all but excludes the timeline category. +int tests_flatten_ci(Test *out, int max); diff --git a/tests/test-app/src/c/tests/registry.c b/tests/test-app/src/c/tests/registry.c index 257e2d6..342d7c9 100644 --- a/tests/test-app/src/c/tests/registry.c +++ b/tests/test-app/src/c/tests/registry.c @@ -22,3 +22,15 @@ int tests_flatten_all(Test *out, int max) { } return idx; } + +int tests_flatten_ci(Test *out, int max) { + int idx = 0; + for (int c = 0; c < NUM_CATEGORIES; c++) { + const Category *cat = s_categories[c]; + if (cat == &category_timeline) continue; + for (int t = 0; t < cat->test_count && idx < max; t++) { + out[idx++] = cat->tests[t]; + } + } + return idx; +} diff --git a/tests/test-app/src/c/windows/main_menu.c b/tests/test-app/src/c/windows/main_menu.c index 30e8f42..45e4637 100644 --- a/tests/test-app/src/c/windows/main_menu.c +++ b/tests/test-app/src/c/windows/main_menu.c @@ -11,7 +11,7 @@ static MenuLayer *s_menu; static Test s_flat_tests[MAX_TOTAL_TESTS]; static uint16_t get_num_rows(MenuLayer *ml, uint16_t section, void *ctx) { - return 1 + NUM_CATEGORIES; + return 2 + NUM_CATEGORIES; } static int16_t get_cell_height(MenuLayer *ml, MenuIndex *idx, void *ctx) { @@ -21,19 +21,24 @@ static int16_t get_cell_height(MenuLayer *ml, MenuIndex *idx, void *ctx) { static void draw_row(GContext *g, const Layer *cell, MenuIndex *idx, void *ctx) { if (idx->row == 0) { + menu_cell_basic_draw(g, cell, "CI TESTS", NULL, NULL); + } else if (idx->row == 1) { menu_cell_basic_draw(g, cell, "TEST ALL", NULL, NULL); } else { - int cat_idx = idx->row - 1; + int cat_idx = idx->row - 2; menu_cell_basic_draw(g, cell, s_categories[cat_idx]->name, NULL, NULL); } } static void select_row(MenuLayer *ml, MenuIndex *idx, void *ctx) { if (idx->row == 0) { + int count = tests_flatten_ci(s_flat_tests, MAX_TOTAL_TESTS); + report_show("CI tests", s_flat_tests, count); + } else if (idx->row == 1) { int count = tests_flatten_all(s_flat_tests, MAX_TOTAL_TESTS); report_show("All tests", s_flat_tests, count); } else { - category_menu_push(s_categories[idx->row - 1]); + category_menu_push(s_categories[idx->row - 2]); } } From a3a73797d3eac65471dd75dd9d888c8b5406298b Mon Sep 17 00:00:00 2001 From: Joe Citrine Date: Tue, 19 May 2026 19:03:09 +0100 Subject: [PATCH 4/7] Local server for serving payloads --- tests/test-app/run-tests.mjs | 7 +++ tests/test-app/src/ts/tests/fetchBinary.ts | 11 ++-- tests/test-app/src/ts/tests/fetchJSON.ts | 3 +- tests/test-app/src/ts/tests/fetchString.ts | 7 +-- tests/test-app/src/ts/tests/nativeFetch.ts | 5 +- tests/test-app/src/ts/tests/serverUrl.ts | 3 + tests/test-app/test-payloads.mjs | 23 ++++++++ tests/test-app/test-server.mjs | 67 ++++++++++++++++++++++ 8 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 tests/test-app/src/ts/tests/serverUrl.ts create mode 100644 tests/test-app/test-payloads.mjs create mode 100644 tests/test-app/test-server.mjs diff --git a/tests/test-app/run-tests.mjs b/tests/test-app/run-tests.mjs index 46018fb..33cc9c1 100755 --- a/tests/test-app/run-tests.mjs +++ b/tests/test-app/run-tests.mjs @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { startTestServer } from "./test-server.mjs"; const PLATFORM = "emery"; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -25,6 +26,9 @@ function run(cmd, args, opts = {}) { } async function main() { + console.log(`${BOLD}==> Starting local test server${RESET}`); + const testServer = await startTestServer(); + console.log(`${BOLD}==> Kill and wipe emulator${RESET}`); await run("pebble", ["kill"]); await run("pebble", ["wipe"]); @@ -61,6 +65,9 @@ async function main() { try { logs.kill("SIGTERM"); } catch {} + try { + testServer.close(); + } catch {} // Backstop: if `pebble kill` hangs or errors, exit anyway after 5s. const backstop = setTimeout(() => { printSummary(); diff --git a/tests/test-app/src/ts/tests/fetchBinary.ts b/tests/test-app/src/ts/tests/fetchBinary.ts index 726f198..5223329 100644 --- a/tests/test-app/src/ts/tests/fetchBinary.ts +++ b/tests/test-app/src/ts/tests/fetchBinary.ts @@ -1,11 +1,14 @@ import { reply } from "../reply"; +import { SERVER } from "./serverUrl"; export const name = "fetchBinary"; export async function run(): Promise { - const bytes = await PebbleTS.fetchBinary( - "https://jsonplaceholder.typicode.com/posts/4" - ); - const ok = bytes instanceof Uint8Array && bytes.length > 0; + const bytes = await PebbleTS.fetchBinary(`${SERVER}/binary`); + const ok = + bytes instanceof Uint8Array && + bytes.length > 0 && + bytes[0] === 9 && + bytes[1] === 8; await reply(ok, `bytes=${bytes.length}`); } diff --git a/tests/test-app/src/ts/tests/fetchJSON.ts b/tests/test-app/src/ts/tests/fetchJSON.ts index 87d44f1..c0162f2 100644 --- a/tests/test-app/src/ts/tests/fetchJSON.ts +++ b/tests/test-app/src/ts/tests/fetchJSON.ts @@ -1,10 +1,11 @@ import { reply } from "../reply"; +import { SERVER } from "./serverUrl"; export const name = "fetchJSON"; export async function run(): Promise { const data = await PebbleTS.fetchJSON<{ id: number; title: string }>( - "https://jsonplaceholder.typicode.com/posts/1" + `${SERVER}/json`, ); const ok = data && data.id === 1 && typeof data.title === "string"; await reply(!!ok, `id=${data?.id} title="${data?.title}"`); diff --git a/tests/test-app/src/ts/tests/fetchString.ts b/tests/test-app/src/ts/tests/fetchString.ts index 0bab15f..3dbb2d1 100644 --- a/tests/test-app/src/ts/tests/fetchString.ts +++ b/tests/test-app/src/ts/tests/fetchString.ts @@ -1,11 +1,10 @@ import { reply } from "../reply"; +import { SERVER } from "./serverUrl"; export const name = "fetchString"; export async function run(): Promise { - const str = await PebbleTS.fetchString( - "https://jsonplaceholder.typicode.com/posts/3" - ); - const ok = typeof str === "string" && str.includes('"id"'); + const str = await PebbleTS.fetchString(`${SERVER}/string`); + const ok = typeof str === "string" && str === "Hello world"; await reply(ok, `len=${str.length}`); } diff --git a/tests/test-app/src/ts/tests/nativeFetch.ts b/tests/test-app/src/ts/tests/nativeFetch.ts index f978bc0..e51f0bd 100644 --- a/tests/test-app/src/ts/tests/nativeFetch.ts +++ b/tests/test-app/src/ts/tests/nativeFetch.ts @@ -1,11 +1,12 @@ import { reply } from "../reply"; +import { SERVER } from "./serverUrl"; export const name = "native fetch"; export async function run(): Promise { - const res = await fetch("https://jsonplaceholder.typicode.com/posts/2"); + const res = await fetch(`${SERVER}/json`); const text = await res.text(); const parsed = JSON.parse(text); - const ok = res.ok && parsed.id === 2; + const ok = res.ok && parsed.id === 1; await reply(ok, `status=${res.status} id=${parsed.id}`); } diff --git a/tests/test-app/src/ts/tests/serverUrl.ts b/tests/test-app/src/ts/tests/serverUrl.ts new file mode 100644 index 0000000..05ca45c --- /dev/null +++ b/tests/test-app/src/ts/tests/serverUrl.ts @@ -0,0 +1,3 @@ +// Base URL for the local test server. Must match TEST_SERVER_PORT in +// ../../test-server.mjs. +export const SERVER = "http://127.0.0.1:9753"; diff --git a/tests/test-app/test-payloads.mjs b/tests/test-app/test-payloads.mjs new file mode 100644 index 0000000..7274ed9 --- /dev/null +++ b/tests/test-app/test-payloads.mjs @@ -0,0 +1,23 @@ +// Fixture responses served by test-server.mjs. Add or edit entries here when +// a test needs a different payload. Each entry maps a path to a response. +// +// body may be: +// - a string → sent as-is +// - an object → JSON.stringify'd, content-type defaults to application/json +// - a Uint8Array / Buffer → sent as bytes + +export const PAYLOADS = { + "/string": { + status: 200, + body: "Hello world", + }, + "/json": { + status: 200, + body: { id: 1, title: "json body", obj: { arr: [1, 2, "3"] } }, + }, + "/binary": { + status: 200, + contentType: "application/octet-stream", + body: new Uint8Array([9, 8, 7, 6, 5, 5, 4, 3, 2, 1]), + }, +}; diff --git a/tests/test-app/test-server.mjs b/tests/test-app/test-server.mjs new file mode 100644 index 0000000..a5c1f3e --- /dev/null +++ b/tests/test-app/test-server.mjs @@ -0,0 +1,67 @@ +// Local HTTP server used by the test app. Serves fixtures defined in +// test-payloads.mjs. Started/stopped by run-tests.mjs; can also be run +// standalone (`node test-server.mjs`) for manual emulator debugging. + +import { createServer } from "node:http"; +import { PAYLOADS } from "./test-payloads.mjs"; + +export const TEST_SERVER_PORT = 9753; + +function encodeBody(entry) { + const { body } = entry; + if (body instanceof Uint8Array) { + return { + buf: Buffer.from(body), + contentType: entry.contentType ?? "application/octet-stream", + }; + } + if (Buffer.isBuffer(body)) { + return { + buf: body, + contentType: entry.contentType ?? "application/octet-stream", + }; + } + if (typeof body === "string") { + return { + buf: Buffer.from(body), + contentType: entry.contentType ?? "text/plain; charset=utf-8", + }; + } + return { + buf: Buffer.from(JSON.stringify(body)), + contentType: entry.contentType ?? "application/json; charset=utf-8", + }; +} + +export function startTestServer(port = TEST_SERVER_PORT) { + const server = createServer((req, res) => { + const path = (req.url ?? "").split("?")[0]; + const entry = PAYLOADS[path]; + if (!entry) { + res.writeHead(404, { "content-type": "text/plain" }); + res.end(`no fixture for ${path}`); + return; + } + const { buf, contentType } = encodeBody(entry); + res.writeHead(entry.status ?? 200, { + "content-type": contentType, + "content-length": buf.length, + }); + res.end(buf); + }); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { + console.log(`test server listening on http://127.0.0.1:${port}`); + resolve(server); + }); + }); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + startTestServer().catch((err) => { + console.error(err); + process.exit(1); + }); +} From 9f25ab9d3bc8bd170499b038ca51899f2e5b58e3 Mon Sep 17 00:00:00 2001 From: Joe Citrine Date: Tue, 19 May 2026 19:18:50 +0100 Subject: [PATCH 5/7] Run tests in ci --- .github/workflows/test.yml | 45 +++++++++++++++++++++++++++++++++--- tests/test-app/run-tests.mjs | 28 ++++++++++++---------- 2 files changed, 58 insertions(+), 15 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 25ce701..549026c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,14 +7,17 @@ on: pull_request: jobs: - build: + test: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Install Node dependencies - run: npm install + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm - name: Cache Node dependencies uses: actions/cache@v4 @@ -24,8 +27,44 @@ jobs: restore-keys: | node-${{ runner.os }}- + - name: Install Pebble SDK system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libsdl2-2.0-0 \ + libglib2.0-0 \ + libpixman-1-0 \ + zlib1g \ + libsndio7.0 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.13" + enable-cache: true + + - name: Install Pebble CLI + run: uv tool install pebble-tool --python 3.13 + + - name: Add Pebble CLI to PATH + run: echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Install Pebble SDK + run: pebble sdk install latest + + - name: Install Node dependencies + run: npm install + - name: Type check run: npm run check-types - name: Build CLI run: npm run build + + - name: Install test-app dependencies + working-directory: tests/test-app + run: npm install + + - name: Run end-to-end tests + working-directory: tests/test-app + run: npm run test diff --git a/tests/test-app/run-tests.mjs b/tests/test-app/run-tests.mjs index 33cc9c1..1478a89 100755 --- a/tests/test-app/run-tests.mjs +++ b/tests/test-app/run-tests.mjs @@ -7,6 +7,11 @@ import { startTestServer } from "./test-server.mjs"; const PLATFORM = "emery"; const HERE = dirname(fileURLToPath(import.meta.url)); const OVERALL_TIMEOUT_MS = 5 * 60 * 1000; +const USE_VNC = process.env.CI === "true" || process.env.CI === "1"; + +function pebbleArgs(...args) { + return USE_VNC ? [...args, "--vnc"] : args; +} const RED = "\x1b[31m"; const GREEN = "\x1b[32m"; @@ -37,10 +42,10 @@ async function main() { await run("pebble", ["build"]); console.log(`${BOLD}==> Installing on ${PLATFORM}${RESET}`); - await run("pebble", ["install", "--emulator", PLATFORM]); + await run("pebble", pebbleArgs("install", "--emulator", PLATFORM)); console.log(`${BOLD}==> Tailing logs${RESET}`); - const logs = spawn("pebble", ["logs", "--emulator", PLATFORM], { + const logs = spawn("pebble", pebbleArgs("logs", "--emulator", PLATFORM), { cwd: HERE, stdio: ["ignore", "pipe", "pipe"], }); @@ -177,18 +182,17 @@ async function main() { // Give pkjs a moment to settle, then trigger TEST ALL (SELECT on row 0). await new Promise((r) => setTimeout(r, 2000)); console.log(`${BOLD}==> pressing SELECT to start TEST ALL${RESET}`); - await run("pebble", [ - "emu-button", - "--emulator", - PLATFORM, - "click", - "select", - ]); + await run( + "pebble", + pebbleArgs("emu-button", "--emulator", PLATFORM, "click", "select"), + ); } main().catch((err) => { console.error(`${RED}${err.stack || err.message || err}${RESET}`); - spawn("pebble", ["kill"], { stdio: "ignore" }).on("exit", () => - process.exit(2), - ); + const exit = () => process.exit(2); + setTimeout(exit, 5000).unref(); + spawn("pebble", ["kill"], { stdio: "ignore" }) + .on("exit", exit) + .on("error", exit); }); From 11c23fd8755298aedc81315a56f7221d881293f4 Mon Sep 17 00:00:00 2001 From: Joe Citrine Date: Tue, 19 May 2026 19:25:33 +0100 Subject: [PATCH 6/7] Attempt to fix qemu launch --- .github/workflows/test.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 549026c..89f513e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,7 +35,8 @@ jobs: libglib2.0-0 \ libpixman-1-0 \ zlib1g \ - libsndio7.0 + libsndio7.0 \ + qemu-system-data - name: Set up uv uses: astral-sh/setup-uv@v5 @@ -52,6 +53,16 @@ jobs: - name: Install Pebble SDK run: pebble sdk install latest + - name: Link QEMU keymaps for headless emulator + run: | + PC_BIOS="${HOME}/.pebble-sdk/SDKs/current/toolchain/lib/pc-bios" + if [ ! -d "${PC_BIOS}" ]; then + echo "Expected pc-bios at ${PC_BIOS}" + ls -la "${HOME}/.pebble-sdk/SDKs/current/toolchain/lib/" || true + exit 1 + fi + ln -sfn /usr/share/qemu/keymaps "${PC_BIOS}/keymaps" + - name: Install Node dependencies run: npm install From 13fc9f1d9fa0352d30e24a3b2a69359c0d9e9e93 Mon Sep 17 00:00:00 2001 From: Joe Citrine Date: Tue, 19 May 2026 22:15:47 +0100 Subject: [PATCH 7/7] Another qemu fix --- .github/workflows/test.yml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89f513e..ebff1d8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,16 +51,12 @@ jobs: run: echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Install Pebble SDK - run: pebble sdk install latest - - - name: Link QEMU keymaps for headless emulator run: | + pebble sdk install latest + SDK_VERSION="$(ls -1 "${HOME}/.pebble-sdk/SDKs" | grep -E '^[0-9]' | sort -V | tail -1)" + pebble sdk activate "${SDK_VERSION}" PC_BIOS="${HOME}/.pebble-sdk/SDKs/current/toolchain/lib/pc-bios" - if [ ! -d "${PC_BIOS}" ]; then - echo "Expected pc-bios at ${PC_BIOS}" - ls -la "${HOME}/.pebble-sdk/SDKs/current/toolchain/lib/" || true - exit 1 - fi + mkdir -p "${PC_BIOS}" ln -sfn /usr/share/qemu/keymaps "${PC_BIOS}/keymaps" - name: Install Node dependencies