/test-native-extension
SkillDev toolsValidate a third-party control repo across four automated layers plus one printed manual recipe. Layer 1 asserts native-source structure (Android getName() and iOS +moduleName to manifest nativeModule; @ReactMethod / RCT_EXPORT_METHOD to methods; no @ReactModule) plus load/init readiness (ReactPackage public no-arg constructor, iOS [cls new] no-arg init, requiresMainQueueSetup NO, non-throwing eager construction), so launch-time crashes surface before any build. Layer 2 validates the committed `./manifest.json` against the ppmplugin-format rules. Layer 3 asserts request/response/error-code agreement across native and PCF. Layer 4 compiles the PCF (auto-skipped if absent). Layer 5 prints a device end-to-end recipe. Native compile belongs to /build-android-binary and /build-ios-binary — this is the cheap structural pre-flight before those slow builds. Reports pass/fail per layer with a fix hint and updates .extension-state.md.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the /test-native-extension skill
What this skill tells your AI
The instructions your AI receives, as published by microsoft/power-platform-skills in plugins/power-apps-mobile-extension/skills/test-native-extension/SKILL.md and read by ahel’s review.
Runs the 4-layer validation ladder for a third-party control repo — the one that ships as a .ppmplugin binary bundle, not as a TypeScript extension. Layers 1–4 are automated; Layer 5 is interactive (requires a real device or simulator and the Companion PCF deployed to a test environment).
| Layer | What | Mode | Speed | Requires |
|---|---|---|---|---|
| 0 | Holistic contract consistency (native ↔ manifest ↔ PCF cross-check) | Automated, warn-only | seconds | at least a native module on disk |
| 1 | Native-source structure asserts (Android getName() ↔ iOS +moduleName ↔ manifest) | Automated, grep/parse | seconds | android/ and/or ios/ |
| 2 | Manifest validation (ppmplugin-format §4 rules) | Automated | seconds (skipped only if no manifest on disk) | ./manifest.json (committed; else staged copy) |
| 3 | Native-source contract asserts (request/response/error grep cross-check) | Automated | seconds | native module(s) |
| 4 | PCF compile (npm run build in pcf/<Pascal>PCF/) | Automated | seconds (after first install) | pcf/<Pascal>PCF/ must exist (skipped otherwise) |
| 5 | Manual device / simulator end-to-end | Recipe-only — skill prints, user runs on own time | 5–10m, off-skill | pcf/ must exist + PCF deployed |
Run order is layer-by-layer for Layers 1–4. Stop on the first failure in the automated layers. Layer 5 is not gated by the skill — it prints the device recipe and exits; the user runs it on their own time and updates .extension-state.md manually.
What this skill does NOT validate: native code compilation into a loadable DEX / framework. That's the job of
/build-android-binaryand/build-ios-binary— they run the real Gradle / xcodebuild toolchain against the pinned RN version and surface the real compiler error. Standalonepod lib lintand./gradlew assembleDebugfrom this skill would give false-confidence (they resolve dependencies from public CDN/maven, not against the wrap host's pinned versions). This skill is the structural pre-flight that runs in seconds with no toolchain — it asserts the native source is shaped correctly (right base class, right symbols, the AndroidgetName()↔ iOS+moduleName↔ manifest agreement) so the build skills don't fail late on a fixable-in-seconds mistake. There is no TypeScript /INativeExtensionlayer in this track to type-check — a native-only.ppmpluginbundle dispatches straight toNativeModules.<nativeModule>.<method>(ppmplugin-format §2— Runtime dispatch contract).
Step 1 — Read the shared docs and PRD
-
Read
shared/shared-instructions.md,shared/naming-conventions.md,shared/ppmplugin-format.md. -
Apply the per-skill minimal prereq policy (
shared-instructions.md §1.5). Layers 1–3 need no toolchain (pure read + grep + validate against the working tree). Layer 4 needs Node + npm only when a PCF is present — and only for the first run (tonpm installthe PCF's own deps from the public npm registry). This track is self-contained and requires no package-feed or source-control authentication (shared-instructions §0a). Run the/test-native-extensioncheck fromprereq-check.md(Layers 0–3 need nothing; Node + npm only if a PCF is present for Layer 4 — there is no "baseline" check in this self-contained track).Print the prereq status as a visible block per
shared-instructions.md §9.2before continuing:━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Prereq check — /test-native-extension ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 🟢 ✓ git installed 🟢 ✓ Node 20+ installed (only needed for Layer 4 — PCF compile) 🟢 ✓ npm installed (only needed for Layer 4 — PCF compile) 🟢 3 checks passed. Ready to proceed.If no
pcf/is present, Node/npm aren't needed at all — note them asn/a (no PCF)rather than failing. Layers 1–3 always run regardless. If any check fails, print the→ Fix:line for that check and STOP. -
Read
./PRD.md. If missing, the Layer 3 contract asserts fall back to the native source itself as source-of-truth (it's still useful) — note it and continue rather than STOP. -
Read
./.extension-state.md. If Phase is belowmanifest(no native module on disk yet), STOP — there's nothing scaffolded to test.
OS-neutral — run these checks with the built-in Read/Grep tools, not a shell. Every extraction/assert in this skill (the
find/grep/sed/awksnippets below) is shown in bash for readability only — it describes what to match, not a shell to execute. RUN them with the agent's built-in Read and Grep tools (plus your own parsing), which behave identically on macOS, Linux, and Windows PowerShell. Do NOT shell out togrep/sed/awk/sort/find— they aren't on a stock Windows box, and Layers 0–3 are deliberately pure read+parse (no toolchain) so they run everywhere. Layer 4'snpm run buildis the only real command, and npm is cross-platform.
Step 2 — Confirm scope with the user
First, auto-detect whether the PCF companion is on disk — use the Grep tool (glob: pcf/**/ControlManifest.Input.xml) so case differences and minor layout variations don't trip the check. (Illustrative bash — don't run it verbatim on Windows):
PCF_MANIFEST=$(find pcf -type f -name "ControlManifest.Input.xml" 2>/dev/null | head -1)
[ -n "$PCF_MANIFEST" ] && PCF_PROJECT_ROOT=$(dirname $(dirname "$PCF_MANIFEST"))
If $PCF_MANIFEST is set, the PCF is scaffolded; use $PCF_PROJECT_ROOT (e.g. pcf/<Pascal>PCF) for Layer 4's build step. If empty, distinguish: no pcf/ at all → "not yet scaffolded"; pcf/ exists but no manifest → "scaffold appears incomplete; re-run /generate-pcf-companion or inspect the folder."
Also detect the manifest that drives Layer 2. Prefer the committed ./manifest.json (the source of truth /generate-native-extension writes at scaffold time, so it normally exists here, right after scaffold) and fall back to the staged build copy:
MANIFEST=$( [ -f ./manifest.json ] && echo ./manifest.json || ls ppmplugin/staging/manifest.json 2>/dev/null )
If $MANIFEST is empty, Layer 2 is skipped (no manifest on disk yet — a hand-authored module that hasn't run /generate-ppmplugin-manifest; with the scaffold, ./manifest.json is present from the start).
This drives Layer 4 (PCF compile) and Layer 5 (manual recipe — both require the PCF to exist).
| State | Default layer set |
|---|---|
No pcf/ folder (PCF not yet scaffolded) | Layers 1, 2, 3 run; Layers 4 and 5 marked deferred — re-run after /generate-pcf-companion |
pcf/ folder exists | Layers 1–4 run automated; Layer 5 prints the device recipe (no wait, no gate) |
Print:
Test plan
─────────
Repo: <cwd>
Extension: @powerapps/extension-<kebab> (class <Pascal>Extension, module <Pascal>Module)
PCF companion: <found pcf/<Pascal>PCF/ | NOT yet scaffolded>
Manifest: <found ./manifest.json (committed) | ppmplugin/staging/manifest.json (staged) | NONE>
Automated layers (skill runs these and reports pass/fail):
0. Holistic contract check — native ↔ manifest ↔ PCF cross-grep (warn-only)
1. Native-source structure — getName()/+moduleName/@ReactMethod asserts + load/init readiness (no-arg package ctor, iOS [cls new]/requiresMainQueueSetup, non-throwing construction)
2. Manifest validation — <ppmplugin-format §4 rules | SKIPPED — no manifest>
3. Native-source contract — request/response/error grep cross-check
4. PCF compile — <npm run build in pcf/<Pascal>PCF | DEFERRED>
Manual layer (skill prints a recipe; you run it on a device on your own time):
5. Device end-to-end — <print recipe | DEFERRED>
Not validated by this skill: native iOS / Android compile into a loadable DEX / framework (run /build-android-binary // /build-ios-binary for that).
Stop on first failure: <yes by default>
Use AskUserQuestion:
Run the test plan?
- Run the default set above (recommended)
- Run only Layers 1–3 (skip PCF compile; skip the Layer 5 recipe)
- Run a single layer (specify which)
- Cancel
If the user picks a single layer, validate dependencies — e.g. "Layer 3 (contract asserts) is more useful after Layer 1 (structure asserts) has passed in this run or a recent run; do you want to skip the check and run anyway?". Don't enforce strictly; surface the implication and let the user decide.
Step 2.5 — Layer 0: Holistic contract consistency
Diagnostic layer, not a hard gate. Reports findings; downgrades to warnings rather than blocking. Useful for catching drift between native modules ↔
manifest.json↔ PCF before the harder-to-debug runtime symptoms surface in Layer 5.
This layer cross-checks the contracts that flow across the native modules, the staged manifest.json, and the PCF. None of these checks compile or run code — they're all grep/parse-based. If any check finds a mismatch, the skill prints a numbered warning with the mismatched values + suggested fix, but continues to Layer 1 unless the user opts to stop. The point is to surface inconsistencies early; the engineer decides which ones matter.
What's checked
| # | Contract | Sources cross-checked | Mismatch surfaces |
|---|---|---|---|
| 1 | Routing key | manifest receivers[].name → ROUTING_KEY const in pcf/<Pascal>PCF/<Pascal>PCF/index.ts | Both must be the same receiver key (our scaffold uses '<Pascal>Extension'; any JS-identifier works — see ppmplugin-format §2). Mismatch = silent routing failure at runtime (the wrap bridge can't dispatch). |
| 1b | PCF transport (wire format) | the dispatch call in index.ts: it MUST call window.PowerApps.NativeExtension.sendAsync("<key>", { method, args: [request] }) and MUST NOT call cordova.exec (or any cordova.*) directly; the sendAsync payload MUST be a raw object (not pre-JSON.stringify'd — sendAsync stringifies internally) and the inner args MUST be [request] (an array). Grep for \.sendAsync\( present AND cordova\.exec absent in the file. | This is the one structural check that maps to a wire-format bug. A direct cordova.exec call passes every other check (the array IS an array, the key IS aligned) and fails only on the first device tap — the raw cordova global is not in the PCF sandbox, so the tap does nothing (worst on Android). Likewise a pre-stringified payload double-encodes → BRIDGE_FAILED. If sendAsync is absent or cordova.exec is present → flag prominently (treat as the highest-priority Layer 0 finding) and point at /generate-pcf-companion and ppmplugin-format §2. |
| 2 | Native module symbol | iOS class name RCT<Pascal>Module == manifest entrypoints.ios.moduleClass; iOS +moduleName return → Android override fun getName() = "<X>" → manifest receivers[].nativeModule | +moduleName, getName(), and receivers[].nativeModule must use the same '<Pascal>Module' (the canonical-prefix rule, ppmplugin-format §3). The Obj-C class name is a separate value that must equal entrypoints.ios.moduleClass. Mismatch = JS-side dispatch finds nothing on one platform but works on the other; or the validator rejects the manifest. |
| 3 | Operation method names | PRD §4 table → RCT_EXPORT_METHOD(<methodName>:...) (iOS) → @ReactMethod fun <methodName>(...) (Android) → manifest receivers[].methods[] | All four must match. Catches typos that compile but route nowhere — and a method missing from methods[] that the host can never dispatch. |
| 4 | Request field names | ARCHITECTURE §4.1 → iOS parser (NSDictionary key reads in .m) → Android parser (ReadableMap key reads in .kt) → PCF payload build (request.<field> in index.ts) | All four must reference the same field names. Mismatch on iOS only / Android only = platform-specific INVALID_INPUT. |
| 5 | Response field names | ARCHITECTURE §4.2 → iOS JSON build (keys in successJsonWith:) → Android JSON build (keys in successJson) → PCF response read (response.result.<field> in index.ts) | All four must match. Catches "PCF reads undefined" issues. |
| 6 | Error codes | ARCHITECTURE §5 error code union → iOS errorJsonWithCode:message: argument strings → Android errorJson(code, message) argument strings → PCF setError case labels → ARCHITECTURE §8 row presence | Each code from §5 should appear in at least one native emit site AND the PCF setError should have a case (or default). Codes emitted by native but not listed in §5 → warn (PRD drift). Codes in §5 but not handled in PCF default → warn (incomplete coverage). |
| 6b | Error MESSAGE plumbing | native error helper signature carries a message (errorJsonWithCode:message: / errorJson(code: String, message: String)) → PCF setError(code, message) is two-arg and sets this.errorMessage → ControlManifest.Input.xml declares Status + ErrorCode + ErrorMessage usage="output" → getOutputs() returns all three | A native helper that still takes only a code, a one-arg setError, or a missing ErrorMessage output = warn: failures will reach the maker as a bare code (or nothing) with no human-readable reason — the on-device debug gap this check exists to close. |
| 7 | PCF manifest properties ↔ index.ts | <property name="..."> in pcf/<Pascal>PCF/<Pascal>PCF/ControlManifest.Input.xml → IInputs / IOutputs references in index.ts (via p.<Name>.raw and getOutputs() return keys) | All manifest properties should be read; all IOutputs keys should appear in getOutputs(). Layer 4's tsc actually enforces this — Layer 0 surfaces it earlier with a more readable diff. |
| 8 | Permissions ↔ §3.2 | ARCHITECTURE §1.4 → iOS Info.plist usage strings (e.g. NSCameraUsageDescription) → Android <uses-permission> entries in AndroidManifest.xml | Each ARCHITECTURE §1.4 row should have a matching native entry. Mismatch = OS denial at runtime with no user-visible message. |
| 9 | RN pin | the React Native pin in package.json devDependencies → android/build.gradle compileOnly RN line → manifest abi / build pins (ppmplugin-format §0 constants) | All should match the wrap host's RN (0.79.7). Drift means the binary is compiled against a different RN than the host loads it into — silent ABI mismatch on device. |
| 10 | Unresolved native references (heuristic) | Scan *.kt, *.m, *.swift files in ios/ and android/. For each function/method call site, verify it has a definition in the same file OR a matching import / #import at the top. | Flags Unresolved reference bugs BEFORE the /build-android-binary // /build-ios-binary compile catches them. Past regressions where helper methods were called but never emitted (e.g. createTopNavBar()) would surface here. |
| 11 | No SDK-era leakage (denylist mirror of /audit-ppmplugin) | Grep the native source + package.json (NOT the PCF) for symbols that belong to the retired TS extension model: an INativeExtension import / implements INativeExtension, a sendAsync transport call, a handleMessageAsync entrypoint, an extensionClassName / jsLayer field, or a @ReactModule annotation on the Android module. | The .ppmplugin ships native binaries only and dispatches straight to NativeModules.<nativeModule>.<method> — none of these symbols belong in the shipped bundle. Any hit in native source / package.json → WARN (it will be a hard CRITICAL at /audit-ppmplugin time, so fix it now). NOTE: sendAsync in the PCF (pcf/…/index.ts) is CORRECT and required — the leakage scan targets only the native/bundle sources, never the PCF. See ppmplugin-format §6 (What this format does NOT cover). |
| 12 | Constructor / init{} safety (crash-at-launch lint) | Scope the scan to the module's construction closure, not the whole file: the primary/secondary constructor(s) + init{} block(s) + property initializers (private val x = … that run at construction), PLUS any private function they call (follow one level of foo() / this.foo()). Within that closure flag: register*Callback(…, null), a bare Handler() / Handler(...) with no explicit Looper, and any side-effecting call (register*/add*Listener/observe/getSystemService+use/file or network I/O/runBlocking) not wrapped in try { } catch. @ReactMethod bodies are OUT of scope (they run per-call, not at construction). | The module is constructed eagerly at bridge startup on a possibly Looper-less thread — an uncaught throw there crashes the host at launch, before any UI (ppmplugin-format §5). Any hit → WARN: defer to lazy first-call init, pass Handler(Looper.getMainLooper()), wrap unavoidable init in try/catch. iOS analogue: the same rule applies to a throwing/heavy init (module instantiated eagerly via [cls new], §5b). Heuristic here — the definitive static catch is now Layer 1's Load & initialization readiness asserts (which hard-gate the clear triggers + the ReactPackage no-arg-ctor / iOS [cls new] / requiresMainQueueSetup load checks); mirrored as /audit-ppmplugin src-ctor-no-throwable-sideeffects; the runtime catch is the Layer 5 launch crash-scan. |
| 13 | PCF unwraps the response container | In pcf/<Pascal>PCF/<Pascal>PCF/index.ts, the invokeBridge / sendAsync success path must run an extractResponse-style unwrap (parse result.data + probe the message container), NOT a bare single JSON.parse. Pattern-match for an extractResponse( call (or an inline "message" in unwrap) on the sendAsync result path. | The wrap transport nests the module's JSON under a message key ({isUpdate, message}); a PCF that only single-parses lands on the container and fails every call with UNEXPECTED_PAYLOAD though native succeeded (ppmplugin-format §2). Missing unwrap → WARN. Mirrored as /audit-ppmplugin Category F pcf-response-unwraps-message. |
| 14 | Listener / resource leak (register without release) | For each register* / add*Listener / observe / getSystemService-acquired resource in the module, check for a matching release (unregister* / remove*Listener / .close() / .release()) in invalidate() / onCatalystInstanceDestroy() / a teardown path. | A registered callback or acquired manager with no release leaks across the module's lifecycle and can fire into a dead module. Missing release → WARN: unregister in invalidate(). |
| 15 | Promise always settled (hang guard) | Each @ReactMethod (Android) / RCT_EXPORT_METHOD (iOS) that takes a Promise / resolver+rejecter must contain at least one promise.resolve / promise.reject (or resolve(...) / reject(...)) on a reachable path. | A method that returns without ever settling its Promise leaves the maker with a hung control and no code/message. A Promise-taking method with zero resolve/reject sites → WARN (guaranteed hang). |
| 16 | Dangerous permission declared but unchecked | If AndroidManifest.xml declares a dangerous permission (CAMERA, RECORD_AUDIO, ACCESS_FINE/COARSE_LOCATION, READ/WRITE_EXTERNAL_STORAGE, READ_CONTACTS, …), the module source must reference checkSelfPermission / ContextCompat.checkSelfPermission / a permission request. | On API 23+ a manifest grant is not enough — calling the API without a runtime check throws SecurityException. Declared-but-unchecked → WARN: check the permission and resolve PERMISSION_DENIED on denial. |
| 17 | currentActivity null-guard | Every currentActivity use in the module must be null-guarded (currentActivity ?: return … / currentActivity?. / an explicit == null check) — flag a bare currentActivity!! or currentActivity.<member> deref. | currentActivity is null when the app is backgrounded; an unguarded deref NPE-crashes the host. Unguarded → WARN: guard and resolve NO_ACTIVITY. |
How it runs
For each check, the skill does a series of grep / read / compare ops:
# Example for check #1 (routing key)
PRD_CLASS=$(grep -oE "Class name \(Pascal\) \| .+" PRD.md | sed 's/.* | //')
MANIFEST_KEY=$(grep -oE '"name"\s*:\s*"[^"]+"' "$MANIFEST" | head -1) # first receiver name ($MANIFEST = ./manifest.json or staged copy)
PCF_KEY=$(grep -oE 'ROUTING_KEY = "[^"]+"' pcf/${PRD_CLASS}PCF/${PRD_CLASS}PCF/index.ts)
# Compare; if mismatch, print:
# ⚠️ Layer 0 check 1 (Routing key): manifest says '<X>', PCF says '<Z>'
# Fix: align both to '<expected>'
For check #10 (unresolved native references), a heuristic grep flow:
# For each .kt file in android/, build the set of in-file definitions + imports,
# then for each call site, check membership.
for kt in $(find android/src -name "*.kt"); do
IN_FILE_FUNS=$(grep -oE 'fun\s+[a-zA-Z_][a-zA-Z0-9_]*' "$kt" | awk '{print $2}' | sort -u)
IMPORTS=$(grep -oE '^import\s+[a-zA-Z0-9_.]+(\.[a-zA-Z0-9_*]+)?$' "$kt" | awk '{print $2}' | awk -F. '{print $NF}' | sort -u)
# Call sites: identifiers followed by `(`, excluding keywords + same-line definitions
CALL_SITES=$(grep -oE '\b[a-zA-Z_][a-zA-Z0-9_]*\(' "$kt" \
| sed 's/($//' \
| grep -vE '^(if|when|while|for|return|require|listOf|arrayOf|mapOf|setOf|Pair|Triple|let|run|with|apply|also|takeIf|takeUnless)$' \
| sort -u)
# Flag any call site not in IN_FILE_FUNS or IMPORTS or known Android/Kotlin builtins
...
done
(Same pattern for .m / .swift with adjusted regexes for Obj-C selectors / Swift function declarations.) This is heuristic — won't perfectly distinguish member calls on imported types from undefined function calls — but catches the headline case (createTopNavBar() invoked with no fun createTopNavBar anywhere and no import that could provide it).
The skill runs all seventeen checks; aggregates findings; prints them as a numbered list at the end of the layer.
Pass
All checks agree across the native modules, the manifest, and the PCF. Print ✓ Layer 0 (contract consistency): pass — <ISO time>.
Warn (continues to Layer 1, doesn't fail the run)
One or more checks found mismatches. Print:
⚠️ Layer 0 (contract consistency): <N> warning(s)
1. Routing key mismatch:
- manifest receivers[].name: '<Pascal>Extension' ✓
- pcf/.../index.ts uses: '<Pascal>' ✗ — fix this
Suggested fix: in pcf/<Pascal>PCF/<Pascal>PCF/index.ts line N, change ROUTING_KEY to "<Pascal>Extension"
2. Response field name mismatch:
- ARCHITECTURE §4.2 expects: 'signatureBase64'
- ios/.../Module.m emits key: 'result' ✗ — should be 'signatureBase64'
- android/.../Module.kt emits key: 'result' ✗
- pcf/.../index.ts reads: response.result.signatureBase64
Suggested fix: ARCHITECTURE §4.2 and the native emit sites disagree. Either update the native modules to emit 'signatureBase64', or update ARCHITECTURE §4.2 + the PCF read to use 'result'.
...
The user decides whether to fix before continuing (re-run after fixing) or proceed to Layer 1 (acknowledging the drift). Use AskUserQuestion:
Layer 0 found contract inconsistencies. Proceed?
- Continue to Layer 1 — warnings recorded in
.extension-state.mdbut don't block- Stop here, fix the warnings first — exit; user re-runs after fixing
Fail (stops the run)
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 859
- Forks
- 176
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
test-native-extension- Source
- github.com/microsoft/power-platform-skills