Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions .github/scripts/determine-widget-scope.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@ to_json_array() {
fi
}

# Is $2 already present as a WHOLE word in the space-separated list $1?
#
# Must not use [[ $list =~ $widget ]]: that is an unanchored regex match, so a widget whose
# name is a substring of one already in the list is silently dropped. Two such pairs exist
# today — image-native ⊂ background-image-native and slider-native ⊂ range-slider-native —
# and `git diff --name-only` emits sorted paths, so the LONGER name always lands first and
# the shorter one is the one lost. A PR touching both built and tested only one of them.
# The name is also interpolated into the regex, where `-` and `.` are metacharacters.
contains_widget() {
case " $1 " in
*" $2 "*) return 0 ;;
*) return 1 ;;
esac
}

if [ "$event_name" == "pull_request" ]; then
# Diff against the MERGE-BASE with the PR base branch so every commit in the PR is
# considered, not just the latest. `before_commit` is the PR base SHA
Expand Down Expand Up @@ -54,15 +69,15 @@ if [ "$event_name" == "pull_request" ]; then
if [[ $file == packages/pluggableWidgets/* ]]; then
widget=$(echo $file | cut -d'/' -f3)
subdir=$(echo $file | cut -d'/' -f4)
if [[ ! $selected_workspaces =~ $widget ]]; then
if ! contains_widget "$selected_workspaces" "$widget"; then
selected_workspaces="$selected_workspaces $widget"
fi
# A change confined to the widget's e2e/ folder (Maestro flows + screenshots) changes
# only the TEST, not the built artifact — so it should NOT trigger a rebuild. The widget
# still goes into selected_workspaces above (it gets TESTED), but it's kept out of the
# BUILD scope; its .mpk comes from the test project's committed baseline, exactly like
# every other not-rebuilt widget in a partial run.
if [[ "$subdir" != "e2e" ]] && [[ ! $build_workspaces =~ $widget ]]; then
if [[ "$subdir" != "e2e" ]] && ! contains_widget "$build_workspaces" "$widget"; then
build_workspaces="$build_workspaces $widget"
fi
elif [[ $file == packages/jsActions/mobile-resources-native/* ]] || [[ $file == packages/jsActions/nanoflow-actions-native/* ]]; then
Expand Down Expand Up @@ -116,11 +131,15 @@ if [ "$event_name" == "pull_request" ]; then
fi
else
if [ -n "$input_workspace" ] && [ "$input_workspace" != "*-native" ] && [ "$input_workspace" != "js-actions" ]; then
# Specific widget(s) selected
selected_workspaces=$(echo "$input_workspace" | sed 's/,/ /g')
# Specific widget(s) selected. The dispatch dropdown is single-select, but a comma-separated
# list is accepted (and already split for `scope` below) — so build the JSON arrays from the
# SPLIT list. "[\"$input_workspace\"]" would emit ["a,b"]: one array entry that matches no
# workspace, so nothing builds and the test matrix spawns a single shard for a widget that
# does not exist.
selected_workspaces=$(echo "$input_workspace" | sed 's/,/ /g' | xargs)
echo "scope=--all --include '${selected_workspaces}'" >> $GITHUB_OUTPUT
echo "widgets=[\"$input_workspace\"]" >> $GITHUB_OUTPUT
echo "widgets_to_test=[\"$input_workspace\"]" >> $GITHUB_OUTPUT
echo "widgets=$(to_json_array "$selected_workspaces")" >> $GITHUB_OUTPUT
echo "widgets_to_test=$(to_json_array "$selected_workspaces")" >> $GITHUB_OUTPUT
echo "js_actions_changed=false" >> $GITHUB_OUTPUT
echo "full_build=false" >> $GITHUB_OUTPUT
elif [ "$input_workspace" == "js-actions" ]; then
Expand Down
104 changes: 94 additions & 10 deletions .github/workflows/NativePipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,28 @@ jobs:
echo "Nothing to build."
fi
- name: "Unit test"
run: pnpm -r --filter="${{ needs.scope.outputs.scope }}" run test
# One --filter per workspace, exactly like "Build resources" above. Passing the whole
# `scope` string as a single filter (--filter="--all --include 'a b'") makes pnpm treat
# that entire string as ONE workspace pattern: it matches nothing, prints "No projects
# matched the filters" and exits 0 — so the unit tests silently never ran.
#
# Tests run for widgets_to_test, not `widgets`: a widget whose only change is under e2e/
# is deliberately not rebuilt but should still have its unit tests run.
run: |
filters=()
if [ "${{ needs.scope.outputs.js_actions_changed }}" = "true" ]; then
filters+=(--filter=mobile-resources-native --filter=nanoflow-actions-native)
fi
while read -r w; do
[ -n "$w" ] && filters+=(--filter="$w")
done < <(echo '${{ needs.scope.outputs.widgets_to_test }}' | jq -r '.[]')

if [ ${#filters[@]} -eq 0 ]; then
echo "No workspaces in scope — nothing to unit test."
exit 0
fi
echo "Unit testing: ${filters[*]}"
pnpm -r "${filters[@]}" run test
- name: "Save built resources (dist) cache"
# Only after a full build (every widget built), so a partial build never populates the
# shared cache with an incomplete dist set under a key a later full build could hit.
Expand Down Expand Up @@ -350,12 +371,61 @@ jobs:
- name: "Overlay built widget mpks"
if: ${{ github.event.inputs.workspace != 'js-actions' }}
shell: bash
# Located by `find` rather than a fixed-depth glob: upload-artifact strips the least-common
# ancestor of the paths it is given, so the depth here follows whatever else is in the
# artifact. Adding resources-manifest.txt at the repo root moved that ancestor from
# packages/ to the root, every path gained a packages/ prefix, and the old
# 'resources/pluggableWidgets/**' glob silently stopped matching — so no built mpk was
# installed and the e2e suite tested the baseline project's widgets instead.
#
# And it is fatal rather than silent: a widget that was built but not installed makes the
# e2e run test something other than the commit under test, which is worse than not running.
run: |
if compgen -G 'resources/pluggableWidgets/**/dist/*/*.mpk' > /dev/null; then
for oldPath in resources/pluggableWidgets/**/dist/*/*.mpk; do
newPath="Native-Mobile-Resources-main/widgets/$(basename "$oldPath")"
mv -f "$oldPath" "$newPath"
done
set -uo pipefail

# A widget's dist/ can hold MORE THAN ONE version dir (e.g. image-native ships
# dist/3.1.1 and dist/3.1.2 after a version bump, since the widget build does not clear
# dist/ the way the jsActions rollup does). Every version emits an identically-named
# .mpk, so installing them in `find` order let an arbitrary — in practice the OLDEST —
# build win and the e2e suite silently tested stale widget code.
#
# Deduplicate on the mpk basename, keeping the highest version dir per widget: key on
# the filename, sort by the parent dir with -V (version sort, so 3.1.10 > 3.1.2), and
# keep the last of each group.
find resources -type f -path '*/dist/*/*.mpk' \
| awk -F/ '{ print $NF "\t" $(NF-1) "\t" $0 }' \
| sort -t"$(printf '\t')" -k1,1 -k2,2V \
| awk -F'\t' '{ best[$1] = $3 } END { for (n in best) print best[n] }' \
| sort > /tmp/mpks.txt

found=0
while IFS= read -r oldPath; do
[ -n "$oldPath" ] || continue
found=$((found + 1))
echo "installing $(basename "$oldPath") (from ${oldPath#resources/})"
mv -f "$oldPath" "Native-Mobile-Resources-main/widgets/$(basename "$oldPath")"
done < /tmp/mpks.txt

# The scope this job was given, read from the manifest rather than needs.scope: `project`
# does not depend on `scope`, so its outputs are not visible here. Compared as a string
# rather than parsed as JSON: the manifest line is written by echoing an interpolated
# expression, and the shell strips the inner quotes on the way in, so it reads
# [a, b] rather than ["a","b"].
built=$(sed -n 's/^built widgets: //p' resources/resources-manifest.txt)
echo "mpks installed: ${found}; widgets in scope: ${built:-unknown}"

# Count the widgets the resources job says it built, and require an .mpk for EVERY one.
# Checking only `found -eq 0` passed a multi-widget run in which just one widget's mpk
# made it through — the rest silently tested the baseline project's stale build, which
# is the same class of failure the zero-check exists to prevent.
expected=$(printf '%s' "$built" | tr -d '[]' | tr ',' '\n' | sed 's/^ *//; s/ *$//' | grep -c . || true)
if [ "${expected:-0}" -gt 0 ] && [ "$found" -lt "$expected" ]; then
echo "::error::Expected ${expected} built widget mpk(s) (${built}) but installed ${found} — the test project would run stale widgets."
echo "--- mpks found under resources/ ---"
cat /tmp/mpks.txt
echo "--- all files under resources/ ---"
find resources -type f | head -50
exit 1
fi
- name: "Register widgets in the test project"
# Run unconditionally: update-widgets must sync the project's widget definitions with the
Expand All @@ -364,17 +434,31 @@ jobs:
# then skip it and leave stale Atlas widget defs for the portable-app build.
shell: bash
run: mx update-widgets --loose-version-check Native-Mobile-Resources-main/NativeComponentsTestProject.mpr
# Same layout-independence as the mpk overlay above: locate the dist dir rather than assume
# its depth in the artifact. Each guard also checks its OWN source dir — nanoflow-actions
# previously tested mobile-resources-native, so it moved either both or neither.
#
# `cp -R <src>/.` rather than `mv <src>/*`: the destination in the test project already
# ships a populated node_modules/, and `mv` refuses to rename a directory over a non-empty
# one ("Directory not empty") instead of merging into it. cp recurses and merges, so the
# built files win per-file while packages the build treats as external (and therefore does
# not collect into dist/node_modules) survive in the project. The trailing /. copies the
# directory's contents including dotfiles, which <src>/* would miss.
- name: "Move mobile-resources"
shell: bash
run: |
if compgen -G 'resources/jsActions/mobile-resources-native/*' > /dev/null; then
mv -f resources/jsActions/mobile-resources-native/* Native-Mobile-Resources-main/javascriptsource/nativemobileresources/actions/
src=$(find resources -type d -path '*/mobile-resources-native/dist' | head -1)
if [ -n "$src" ]; then
echo "installing mobile-resources from $src"
cp -Rf "$src"/. Native-Mobile-Resources-main/javascriptsource/nativemobileresources/actions/
fi
- name: "Move nanoflow-actions"
shell: bash
run: |
if compgen -G 'resources/jsActions/mobile-resources-native/*' > /dev/null; then
mv -f resources/jsActions/nanoflow-actions-native/* Native-Mobile-Resources-main/javascriptsource/nanoflowcommons/actions/
src=$(find resources -type d -path '*/nanoflow-actions-native/dist' | head -1)
if [ -n "$src" ]; then
echo "installing nanoflow-actions from $src"
cp -Rf "$src"/. Native-Mobile-Resources-main/javascriptsource/nanoflowcommons/actions/
fi
- name: "Build portable app package (self-contained runtime)"
# Run this BEFORE the native-packager deploy: portable-app-package re-prepares and
Expand Down
11 changes: 9 additions & 2 deletions maestro/helpers/helpers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,12 @@ run_maestro() {
return "$status"
}

# Move a fault's evidence aside BEFORE the retry, which reuses both paths.
# Move an attempt's evidence aside BEFORE the retry, which reuses both paths and would otherwise
# delete it: start_recording rm -f's the same REC_FILE and run_maestro rm -rf's the same
# DEBUG_RUN_DIR, so a flake that passes on retry left no video and no hierarchy to diagnose from.
preserve_fault_artifacts() {
local video="${1:-}"
local suffix="driver-fault"
local suffix="${2:-driver-fault}"
if [ -n "$video" ] && [ -f "$video" ]; then
mv -f "$video" "${video%.mp4}-${suffix}.mp4" 2>/dev/null || true
fi
Expand Down Expand Up @@ -318,7 +320,12 @@ run_tests() {
fi
else
echo "❌ Test failed: $yaml_test_file"
# Capture the path before stop_recording clears REC_FILE.
local failed_video="$REC_FILE"
stop_recording keep
# The retry reuses both paths, so a flake that passes on retry would erase the only
# evidence of the failure. Keep this attempt's video and hierarchy under -attempt1.
preserve_fault_artifacts "$failed_video" "attempt1"
failed_tests+=("$yaml_test_file")
fi
completed_tests=$((completed_tests + 1))
Expand Down
4 changes: 4 additions & 0 deletions packages/pluggableWidgets/intro-screen-native/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Fixed

- We fixed an issue where the IntroScreen did not show the slide set by the active slide attribute, and where swiping between slides did not work reliably on slower Android devices.

## [4.4.1] - 2026-6-10

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,33 @@ appId: "${APP_ID}"
timeout: 5000
- assertVisible:
text: "Changes: 0"
# Coordinates match Gallery_native_horizontal.yaml, which swipes a horizontal list reliably, and
# keep the two directions symmetric — which `direction:` alone also does, but not visibly.
#
# The wait is the part that matters. A slide change writes the active slide attribute and waits for
# the runtime to hand the value back, so the JS thread is still busy for a moment after the
# assertions above pass. Swiping into that window loses the opening touch-move events, so the list
# lags the finger and can be short of the halfway point when the touch lifts — it then snaps back,
# with every counter correctly still on the old slide.
#
# Duration is deliberately left at the default: a paging list commits on position OR lift-off
# velocity, so a slower drag is worse, not better — same distance travelled, less velocity to carry
# it over, and on a slow device the position term is the part already degraded.
- waitForAnimationToEnd:
timeout: 2000
- swipe:
direction: LEFT
start: 90%, 10%
end: 15%, 10%
- extendedWaitUntil:
visible: "Active slide: 3"
timeout: 5000
- assertVisible:
text: "Changes: 1"
- waitForAnimationToEnd:
timeout: 2000
- swipe:
direction: RIGHT
start: 15%, 10%
end: 90%, 10%
- extendedWaitUntil:
visible: "Active slide: 2"
timeout: 5000
Expand Down Expand Up @@ -53,6 +71,11 @@ appId: "${APP_ID}"
timeout: 5000
- tapOn:
text: "NEXT"
# NEXT is what turns into FINISH on the last slide, so tapping straight through races the
# re-render: wait for the slide the button belongs to before reaching for it.
- extendedWaitUntil:
visible: "Active slide: 3"
timeout: 5000
- tapOn:
text: "FINISH"
- extendedWaitUntil:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "intro-screen-native",
"widgetName": "IntroScreen",
"version": "4.4.1",
"version": "4.4.2",
"license": "Apache-2.0",
"repository": {
"type": "git",
Expand Down
Loading
Loading