diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cec5e3d..ac71e2c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -4,13 +4,13 @@ "name": "aleqsio" }, "metadata": { - "description": "Map every screen of an Expo / React Native app, and see what a pull request changes on-screen." + "description": "Map every screen of an Expo / React Native or NativeScript app, and see what a pull request changes on-screen." }, "plugins": [ { "name": "screenmap", "source": "./plugins/screenmap", - "description": "Map every screen of an Expo app from your own terminal, and pack it into a .scrmap bundle you can open in the viewer." + "description": "Map every screen of an Expo, React Native or NativeScript app from your own terminal, and pack it into a .scrmap bundle you can open in the viewer." } ] } diff --git a/README.md b/README.md index 51d1bb7..7236cba 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # screenmap -screenmap shows you every screen in an Expo / React Native app, and shows your reviewers exactly which screens a pull request changed, without anyone writing a test. +screenmap shows you every screen in an Expo / React Native or NativeScript app, and shows your reviewers exactly which screens a pull request changed, without anyone writing a test. Web pull requests get a preview URL. Mobile pull requests get a QR code, a build to install, and a screen to find on your own, so most reviewers skip the visual half of the change. screenmap closes that gap: it runs the app in CI, screenshots the screens your diff can reach, and posts them into the review. @@ -59,7 +59,7 @@ PR opened ──▶ restore baseline (screenmaps branch) ──▶ static parse You need all four of these: -- An Expo or React Native app using expo-router or a react-navigation route map, with `expo-dev-client` installed and deep-linkable routes. +- An Expo or React Native app using expo-router or a react-navigation route map, with `expo-dev-client` installed and deep-linkable routes. Or a NativeScript app (Angular Router, or Core XML pages): no dev client and no EAS lane, so you pass a prebuilt `.app` / `.apk` as `app_path` and name the routes your URL handler opens under `routes.links` — see [NativeScript apps](#nativescript-apps). - An EAS build profile that produces a simulator build (iOS) or an APK (Android — set `"android": { "buildType": "apk" }`, since an `.aab` cannot be installed on an emulator), or a build of your own. - A GitHub repo you can add secrets to. - Runner minutes. iOS needs macOS, which bills at ten times the Linux rate; Android runs on `ubuntu-latest`. A JavaScript-only pull request takes about 12 minutes end to end. @@ -130,7 +130,10 @@ You need all four of these: | `android.appId` | read from the APK with `aapt2` | Package name. Also accepted as `android.packageName` | | `appName` | the project directory name | Name recorded in the bundle | | `device`, `bundleId`, `appPath` | — | Pre-multi-platform spellings of the `ios.*` keys above; still honoured | - | `metroPort` | `8081` | Port Metro starts on | + | `metroPort` | `8081` | Port Metro starts on (Expo only) | + | `runtime` | detected | `expo` or `nativescript`. Detected from `nativescript.config.*`; a NativeScript session launches the installed app instead of steering a dev client onto Metro | + | `routes.provider` | `auto` | Pin the route provider (`expo-router`, `react-navigation`, `nativescript`, `custom`) when detection is wrong. See [docs/route-providers.md](docs/route-providers.md) | + | `routes.links` | `{}` | NativeScript only: which routes a deep link opens, route id → path after the scheme (`true` for the route's own URL, `"*"` as the default). Without it every screen is reached by tapping | | `params` | `{}` | Real values for route parameters, see below | | `suspects.depth` | from `effort` | Import hops followed out from a changed file | | `suspects.broadCap` | `8` | Cap on screens marked by a change to a widely imported file | @@ -174,7 +177,7 @@ In a monorepo, point the Action at the app with `project: apps/mobile`. On a pri | Input | Default | What it does | | --- | --- | --- | | `mode` | required | `pr` or `baseline` | -| `project` | `.` | Path to the Expo project, relative to the repo root | +| `project` | `.` | Path to the app project (Expo or NativeScript), relative to the repo root | | `agent_provider` | `claude` | `claude`, `codex`, `gemini` or `opencode`. See [AI providers](#ai-providers) | | `agent_api_key` | empty | Key for the chosen provider. Leave empty for deterministic-only runs | | `effort` | `balanced` | `fast`, `balanced` or `thorough` — tokens and wall-clock against accuracy. See [Install](#install) step 3 | @@ -185,7 +188,7 @@ In a monorepo, point the Action at the app with `project: apps/mobile`. On a pri | `publish` | `"true"` | Publish bundles to the `screenmaps` branch so the comment can deep-link the viewer. Needs `contents: write` | | `viewer_url` | `https://app.screenmap.dev` | Viewer origin used in comment links | | `simulator` | `iPhone 17 Pro` | Device to boot, falling back to any available iPhone | -| `app_path` | empty | A prebuilt simulator `.app`. When set, EAS is skipped | +| `app_path` | empty | A prebuilt simulator `.app` (or `.apk`). When set, EAS is skipped. The only option for a NativeScript app | | `expo_token` | empty | `EXPO_TOKEN` for the EAS lane. Required unless `app_path` is set | | `eas_profile` | `development-simulator` | The `eas.json` profile used for the dev client | | `flows_pr` | `"true"` | Baseline runs open a PR with the flows the agent recorded | @@ -273,7 +276,7 @@ claude plugin marketplace add aleqsio/screenmap claude plugin install screenmap@screenmap ``` -Then, in any Expo or React Native project, run one of these in Claude Code: +Then, in any Expo, React Native or NativeScript project, run one of these in Claude Code: ``` /screenmap # full run: parse + simulator exploration + pack @@ -290,6 +293,22 @@ What you get: Output lands in `/.screenmap/out/`, so add that to your `.gitignore`. You need a macOS host with the iOS simulator, or an Android emulator (`--platform android`); `--platform both` captures each screen on both and puts them in one map behind a platform switcher. +## NativeScript apps + +The same pipeline reads a [NativeScript](https://nativescript.org) app, with three differences that follow from how NativeScript apps are built. + +- **Routes come from whichever framework drives the views.** For Angular, the `nativescript` provider follows `provideNativeScriptRouter(routes)` (or `NativeScriptRouterModule.forRoot`) into every `loadChildren` module, reads named outlets, `redirectTo` aliases, enum-valued paths and barrel re-exports, and takes edges from `navigate([...])`, `navigateByUrl()` and `nsRouterLink`. A route with `children` is a layout, read as tabs when its children sit in several named outlets. Modals and material bottom sheets opened from a screen become state hints naming the component. Core apps (no framework) are read from their `` XML files and `Frame.navigate({ moduleName })` calls. For Octane, React NativeScript, Vue, Svelte and Solid, a screen is a component the framework mounts as a root (`renderNativeScriptApp`, `ReactNativeScript.start`, `createApp`, `svelteNative`, `startSolidApp`), pushes (`$navigateTo`, `navigate({ page })`, a `solid-navigation` `navigate('Name')` against its `` table), presents (`$showModal`, `showModal({ page })`, a second root rendered into a view handed to `showModal`) or hosts in a `` tab; the entry's mount is the app root, a `` table's `initialRouteName` is the root behind a router shell, and a `` or a native `menu=` prop becomes a state hint. +- **Deep links are declared, not inferred.** The app registers its URL scheme in `App_Resources` (read automatically, `${BUNDLE_IDENTIFIER}`-style variables included), but which screen a link opens is the app's own code. So every route starts navigation-only, and `.screenmap/config.json` says which ones a link reaches: + + ```json + { "routes": { "links": { "talk/today": "today", "chatbot/chat": "ask-mae", "settings": true, "*": false } } } + ``` + + Keys are route ids, values the path after the scheme, `true` for the route's own URL, `"*"` as the default. Everything not listed goes to the agent lane to be reached by tapping. An app that registers no URL scheme at all still maps: the root screen is captured by launching the app, and the rest through committed flows or the agent. +- **There is no Metro and no dev client.** The JS bundle ships inside the app, so a session installs and launches the build and that is the whole boot. Locally, `ns build ios` puts the simulator app under `platforms/ios/build/Debug-iphonesimulator/` and `ns build android` the APK under `platforms/android/app/build/outputs/apk/debug/`; both the plugin and `screenmap-ci` find them there. It has to be `ns build`: under `@nativescript/vite`, `ns debug` and `ns run` produce a stub that imports every module from the Vite dev server, which is not there in a headless run, and `screenmap-ci` refuses such a build by name rather than capturing the home screen. Debug and release builds can sit side by side; the newest one is used. In CI the Action never builds, so a build step produces the `.app` or `.apk` and passes it as `app_path`; the EAS lane and `expo_token` do not apply. + +Angular, Octane, Vue and Solid are validated against real apps (a 54-screen NativeScript Angular app with lazy route modules and named-outlet tabs, an Octane chat app with a drawer and a native settings sheet, and three Apple Music clones in Angular, Vue and Solid with tab-hosted screens and shared-transition modals); Core, React and Svelte against fixtures. Flow replay, PR diffs and the agent lane run unchanged, with one caveat: a PR that changes native code needs a rebuild per side, since there is no Metro to restart. + ## The map viewer The hosted viewer lives at [app.screenmap.dev](https://app.screenmap.dev). Drop a `.scrmap` file (the **Map**) and, if you have one, a `.diff.scrmap` file (the **Changes** overlay for a pull request). Parsing happens in your browser, so nothing is uploaded. Opening the site with no parameters gives you the drop screen. @@ -326,7 +345,7 @@ Drop a `.scrmap` bundle on the landing page. The demo bundle ships in `public/de + nav/interaction flows screens/*.png) tap overlays ``` -1. **Static parse** (no dependencies). Reads expo-router file conventions and react-navigation route maps (the kind Bluesky keeps in `src/routes.ts`), so the screen list is complete rather than whatever a crawler happened to find. It produces the route list, navigation edges from `Link` and `navigate()` calls, and state hints saying which screens use a bottom-sheet or dialog system. +1. **Static parse** (no dependencies). Reads expo-router file conventions, react-navigation route maps (the kind Bluesky keeps in `src/routes.ts`), and NativeScript apps — Angular Router `Routes` arrays or Core XML pages — so the screen list is complete rather than whatever a crawler happened to find. It produces the route list, navigation edges from `Link`, `navigate()` and `nsRouterLink` calls, and state hints saying which screens use a bottom-sheet or dialog system. 2. **Agent exploration** in the iOS simulator or Android emulator. A deep-link sweep captures every screen and classifies each capture (real, empty state, not found, error boundary, auth wall). For the screens a deep link cannot reach, an agent drives the app and records the tap path as an [argent](https://argent.swmansion.com) flow in YAML, replayable later with `argent flow run`. Runtime states get captured too: open drawers, bottom-sheet snap points, dialogs. If a sticky error boundary blocks the app, the agent recovers and carries on. 3. **Pack.** Everything merges into a producer-agnostic `.scrmap` zip. The format contract is in [docs/scrmap-format.md](docs/scrmap-format.md), which is what you need if you want to write your own producer. 4. **Visualise.** The viewer draws a top-down graph with the root screen at the top-center and phone-framed screenshots. Load a second bundle, a `.diff.scrmap`, and it overlays what a pull request changed. @@ -337,13 +356,13 @@ The expensive part is step 2, and you only pay it once. Recorded flows get commi - `plugins/screenmap/skills/screenmap/SKILL.md` is the agent orchestration: phases, safety rails, and the flow-recording contract. - `plugins/screenmap/skills/screenmap/scripts/` holds `parse-routes.mjs`, `pack-map.mjs`, `diff-map.mjs` (PR diff: suspects and pack) and `render-map.mjs` (static HTML fallback). All plain Node, no dependencies. -- `plugins/screenmap/skills/screenmap/scripts/routes/` is the route-provider layer: `parse-routes.mjs` only drives it, and each framework (expo-router, react-navigation, or a command you supply) is one module under `providers/`. See [docs/route-providers.md](docs/route-providers.md) to add one. +- `plugins/screenmap/skills/screenmap/scripts/routes/` is the route-provider layer: `parse-routes.mjs` only drives it, and each framework (expo-router, react-navigation, nativescript, or a command you supply) is one module under `providers/`. See [docs/route-providers.md](docs/route-providers.md) to add one. - `apps/visualiser/` is the Map / Changes viewer, built with Vite, React, Tailwind v4, shadcn/ui, React Flow and elkjs, plus pixelmatch and OpenCV.js for the visual diff. - `action.yml` is the composite GitHub Action. The metadata sits at the repo root so the repo is publishable to the Marketplace. Its `screenmap-ci` CLI lives in `action/cli`, and the workflow and `.scrmap` templates live in `action/templates`. - [`docs/scrmap-format.md`](docs/scrmap-format.md) is the versioned bundle format contract, for writing your own producer. - [`docs/diff-scrmap-format.md`](docs/diff-scrmap-format.md) is the format of the PR diff bundle behind the Changes view. - [`docs/ci.md`](docs/ci.md) is a stub pointing at the CI section above, which is where that guide lives now. -- `fixtures/demo-app/` is a minimal expo-router app that exercises the parser. +- `fixtures/demo-app/` is a minimal expo-router app that exercises the parser; `fixtures/rn-demo-app/`, `fixtures/ns-angular-demo-app/` and `fixtures/ns-core-demo-app/` do the same for the other providers, and `fixtures/run-tests.mjs` pins every graph. ## Known limits @@ -351,7 +370,7 @@ The expensive part is step 2, and you only pay it once. Recorded flows get commi - **Android's dev-menu muting is best-effort.** iOS writes the preference through `simctl spawn defaults`; Android has to reach the app's SharedPreferences through `run-as`, which only works for a debuggable build. When it fails, the dev-menu floating button stays in the captures — cosmetic, and the run continues. - **OCR on Linux is tesseract, not Vision.** The landing checks, deep-link verification and system-alert dismissal all read the screen, and tesseract recovers noticeably fewer words than Apple Vision. Screen-to-screen comparisons hold up (same-text scores are unchanged; different-screen scores only move further apart), but a landmark check is likelier to miss, so a drift warning from a Linux run is less certain than one from macOS. The run summary and the PR comment name the backend when it is not Vision. - **Flows are per platform.** Coordinates are normalized, but layouts and system chrome are not, so a flow recorded on iOS is not guaranteed to replay on Android. Record and commit them per platform. -- **Your app needs a router screenmap can read.** expo-router file conventions or a react-navigation route map. Screens registered without URLs are invisible to the static parse, and only show up through agent exploration. +- **Your app needs a router screenmap can read.** expo-router file conventions, a react-navigation route map, or a NativeScript Angular Router / Core page tree. Screens registered without URLs are invisible to the static parse, and only show up through agent exploration. A NativeScript app's deep links are whatever its own URL handler does, so its screens are navigation-only until `routes.links` names them; Octane, React, Vue and Svelte apps are read from their mount, push and modal calls, so a screen wired any other way is invisible until an agent finds it. - **Edge extraction is regex-based**, so dynamic hrefs resolve to their route pattern. - **It reports, it does not gate.** There is no pass/fail check, by design. A reviewer decides what the screenshots mean. diff --git a/TODOS.md b/TODOS.md index 9ecb182..cd2cfaa 100644 --- a/TODOS.md +++ b/TODOS.md @@ -6,6 +6,81 @@ the full write-up of what that turned up is in `site/docs/setup-instruction-fixe --- +## NativeScript: what the first pass verified, and what it did not + +Landed 2026-09-15 on `feat/nativescript`. One provider, six flavours, validated +against five real apps: HeyKiddo Talk (a 54-screen NativeScript Angular app +with lazy `loadChildren` modules and named-outlet tabs), ns-octane (an Octane +chat app with a drawer and a native settings sheet), and the three Apple Music +clones in ns-apple-music (Angular, Vue and Solid, each with tab-hosted screens +and a shared-transition modal). + +Verified: + +- **Static parse, every real app.** Detection scores 0.95 with nothing else + above 0.10. HeyKiddo: 54 routes, 9 layouts, 61 edges, one unresolved edge + to a route that is commented out in `app.routes.ts` (the parser telling the + truth). ns-octane: root + modal sheet, one edge, a drawer hint. Apple Music: + the five tab screens come out under `Home` as `Tabs` in all three flavours, + the album screen is reached from four of them, the now-playing sheet is a + modal, and Solid's `` table names its screens with the router shell + read as a layout. Angular's `''` landing resolves to the first tab as `/`. +- **The `routes.links` overlay.** 20 HeyKiddo routes get a deep link from a + config that mirrors the app's `DEEP_LINKS` table; the rest stay + navigation-only. +- **The iOS deterministic lane, no Metro.** `screenmap-ci baseline` installed + each `ns build ios` output from `platforms/ios/build`, launched it, and + captured: all 20 linked HeyKiddo routes by deep link (logged in, zero + failures), and the root of ns-octane, ns-vue, ns-angular and ns-solid by + launching the app, since none of the four registers a URL scheme. +- **Flow replay and landing verification.** 22 hand-recorded argent flows + (tab taps, the featured album, the now-playing sheet, the visualizer, + Octane's drawer and settings sheet) replayed through `argent flow run` with + every landmark check passing, so every screen of the four scheme-less apps + is a real capture: Octane 3 shots including the drawer state, Vue 9 of 9, + Angular 6 of 6, Solid 8 of 8. A flow that starts with `launch:` restarts + the app, which is what replaces `open-url` when there is no scheme. +- **Solid at runtime, from a release build.** ns-solid's debug build halts on + launch with Solid's `[REACTIVITY_HALTED] … PRIMITIVE_IN_FORBIDDEN_SCOPE` + thrown from `solid-navigation`'s stack item (a dev-only assertion), so it + renders black; `ns build ios --release` renders and replays fine. The build + finder now takes the newest build, so the release one is picked up. +- **`ns debug` output is refused, by name.** Under `@nativescript/vite`, `ns + debug` / `ns run` write a stub bundle whose `@nativescript/core` imports are + `http://:5173/ns/core…` URLs; headless it dies on the first import + ("HTTP import failed") and every capture is the simulator home screen, which + is exactly what the first ns-octane run produced. The session now reads + `app/bundle.mjs` and fails with the dev-server origin and the `ns build` + command instead. + +Still unverified: + +- **Android runtime.** `findBuiltApp` knows `platforms/android/app/build/outputs/apk`, + the session skips `adb reverse` and the dev-menu muting, and the stub guard + reads `assets/app/bundle.mjs` out of the APK, but no NativeScript APK has + been run through the lane. A `com.heykiddo.talk` debug APK and three AVDs + exist on the dev machine. +- **The agent lane.** The prompt says "bundled JS, no Metro" (and "no URL + scheme" where that holds), but no agent has explored a NativeScript app + unattended; the 22 flows above were recorded by hand from argent + `describe` output. HeyKiddo's 34 navigation-only screens are the natural + test. +- **PR diffs.** Nothing changes in the code path, but a NativeScript PR that + touches native code needs `ns build` per side rather than a Metro restart, + and no diff has been run end to end. +- **Core, React and Svelte on real apps.** Fixtures only. React NativeScript + is root-only until its navigation idioms are pinned down. +- **Deep-link inference.** `routes.links` is hand-written. HeyKiddo's table is + a declarative object; a heuristic that reads `navigate(...)` commands out of + an app's own URL handler would cover apps like it, but it is one app's shape + and not worth building from one sample. +- **Login in the deterministic lane.** The keychain token survives + `simctl install`, so signing in once on the simulator is enough locally; CI + starts from a clean device and has no answer beyond the agent lane and + `{{secret:…}}` placeholders. A NativeScript app's `.screenmap/SKILL.md` + also has to say which build flavour and account to use: HeyKiddo needs + `ns build ios --env.TEST_ENV` or every deep link lands on the login wall. + ## Android: verified on CI, and what the first runs cost Android support landed on 2026-09-01 and ran green on GitHub Actions on diff --git a/action.yml b/action.yml index 2936ab9..efad611 100644 --- a/action.yml +++ b/action.yml @@ -2,7 +2,7 @@ # collide with AppLand's AppMap listing, hence the repo-matching name. name: screenmap description: >- - Map every screen of an Expo / React Native app and review what a pull request changes on-screen. + Map every screen of an Expo / React Native or NativeScript app and review what a pull request changes on-screen. PR runs diff the head against a cached baseline map, capture only the affected screens on an iOS simulator or Android emulator, and post a sticky comment linking a preloaded map viewer. Baseline runs refresh the map of main incrementally and open a flows PR for screens the agent had to explore. @@ -16,7 +16,7 @@ inputs: description: "pr | baseline" required: true project: - description: Path to the Expo project (repo-relative) + description: Path to the app project (Expo or NativeScript), repo-relative default: "." platform: description: >- @@ -131,7 +131,7 @@ runs: echo "::error::platform=ios needs a macOS runner (iOS simulator)"; exit 1 fi if [ -z "${{ inputs.app_path }}${{ inputs.expo_token }}" ]; then - echo "::error::provide expo_token (EAS builds the dev client) or app_path (bring your own build)"; exit 1 + echo "::error::provide expo_token (EAS builds the dev client) or app_path (bring your own build; the only option for a NativeScript app)"; exit 1 fi if [ "${{ inputs.platform }}" = "ios" ]; then xcrun simctl list devices available | head -20 diff --git a/action/cli/lib/agent.mjs b/action/cli/lib/agent.mjs index bdf97fa..e20077c 100644 --- a/action/cli/lib/agent.mjs +++ b/action/cli/lib/agent.mjs @@ -122,9 +122,9 @@ export function runAgent({ projectDir, config, screens, scheme, udid, bundleId, const brief = PLATFORM_BRIEF[platform] ?? PLATFORM_BRIEF.ios const device = deviceName ?? config.device ?? brief.device - const prompt = `You are running the screenmap skill's capture phases headlessly in CI on ${platform.toUpperCase()} (no simulator MCP — use ${brief.cli} and the \`argent\` CLI for taps/swipes: \`argent run …\` (\`argent tools\` lists them; its device tools take this ${brief.device}'s id directly; if \`argent\` is not on PATH, run \`npx -y @swmansion/argent@0.21.0\` from a directory OUTSIDE the project, e.g. /tmp, because this repo's devEngines pin breaks npx inside it)). The app is already running on ${brief.device} ${udid} (${brief.appId} ${bundleId}, scheme ${scheme}://), Metro is up. Do not rebuild, reinstall, or checkout anything. + const prompt = `You are running the screenmap skill's capture phases headlessly in CI on ${platform.toUpperCase()} (no simulator MCP — use ${brief.cli} and the \`argent\` CLI for taps/swipes: \`argent run …\` (\`argent tools\` lists them; its device tools take this ${brief.device}'s id directly; if \`argent\` is not on PATH, run \`npx -y @swmansion/argent@0.21.0\` from a directory OUTSIDE the project, e.g. /tmp, because this repo's devEngines pin breaks npx inside it)). The app is already running on ${brief.device} ${udid} (${brief.appId} ${bundleId}, ${scheme ? `scheme ${scheme}://` : 'no URL scheme — every screen is reached by tapping from launch'})${config.runtime === 'nativescript' ? ' with its JS bundled inside (a NativeScript app: no Metro, no dev client)' : ', Metro is up'}. Do not rebuild, reinstall, or checkout anything. -Deep link: ${brief.deepLink(udid, `${scheme}://some/path`)} +Deep link: ${scheme ? brief.deepLink(udid, `${scheme}://some/path`) : 'none — this app registers no URL scheme'} Screenshot: ${brief.shot(udid, `${outScreensDir}/.png`)} Read the skill at ${SKILL_DIR}/SKILL.md for conventions (capture naming, flow recording format, safety rules: never tap destructive/purchase/sign-out controls, never record credentials). The project lives at ${projectDir}. All output paths below are absolute — write to them exactly. @@ -134,8 +134,8 @@ ${budgeted.map((s) => `- ${s.id} ${s.deepLink ? `urlPath=${s.urlPath} deepLink Rules: 1. Screenshots go to ${outScreensDir}/.png (state variants: --.png). Use exactly these slugs. -2. Flows go to ${outFlowsDir}/ as argent YAML + .meta.json sidecars (formatVersion 2) — nav- for the tap path from app launch (\`${scheme}://\`), visit- for the bare deep link, plus one flow per state variant you capture. Coordinates normalized 0–1 for a ${device}. Every sidecar MUST include \`"landmarks": [2–5 words visible on the arrival screen that identify it — titles/section headers/fixed labels, never live content]\`; CI verifies replays by OCR-ing for them. -3. Prefer the deep link first; if it shows an error/not-found, find real params (public API, other screens) and note what you used. A screen marked NO DEEP LINK has no URL at all — do NOT open \`${scheme}://\` and screenshot whatever appears, which would file the home screen under its name. Navigate to it by tapping, and let its nav flow be its capture. +2. Flows go to ${outFlowsDir}/ as argent YAML + .meta.json sidecars (formatVersion 2) — nav- for the tap path from app launch (${scheme ? `\`${scheme}://\`` : 'launch-app'}), visit- for the bare deep link, plus one flow per state variant you capture. Coordinates normalized 0–1 for a ${device}. Every sidecar MUST include \`"landmarks": [2–5 words visible on the arrival screen that identify it — titles/section headers/fixed labels, never live content]\`; CI verifies replays by OCR-ing for them. +3. Prefer the deep link first; if it shows an error/not-found, find real params (public API, other screens) and note what you used. A screen marked NO DEEP LINK has no URL at all — do NOT open ${scheme ? `\`${scheme}://\`` : 'the app root'} and screenshot whatever appears, which would file the home screen under its name. Navigate to it by tapping, and let its nav flow be its capture. 4. Write ${notesPath}: JSON { "": "one sentence describing what this screen shows${mode === 'pr' ? ' / what visibly changed in this PR' : ''}" } for each screen you handled${mode === 'pr' ? ', or { "note": "...", "verdict": "unaffected" } if the PR diff shows no visible change there' : ''}. 5. Write ${summaryPath}: JSON { "captured": [routeIds], "skipped": [{ "id", "why" }], "flows": [flow names] } when done. 6. Budget: these ${budgeted.length} screens only. Be economical — no broad exploration.${prContext ? `\n\nPR context: ${prContext}` : ''}` diff --git a/action/cli/lib/android.mjs b/action/cli/lib/android.mjs index f047ad5..6591108 100644 --- a/action/cli/lib/android.mjs +++ b/action/cli/lib/android.mjs @@ -183,17 +183,17 @@ export function freezeStatusBar(id) { demo('-e', 'command', 'notifications', '-e', 'visible', 'false') } +// Expo's `run:android` output and NativeScript's `ns build android`. export function findBuiltApp(projectDir) { const roots = [ path.join(projectDir, 'android', 'app', 'build', 'outputs', 'apk', 'debug'), path.join(projectDir, 'android', 'app', 'build', 'outputs', 'apk', 'release'), + path.join(projectDir, 'platforms', 'android', 'app', 'build', 'outputs', 'apk', 'debug'), + path.join(projectDir, 'platforms', 'android', 'app', 'build', 'outputs', 'apk', 'release'), ] - for (const d of roots) { - if (!fs.existsSync(d)) continue - const apk = fs.readdirSync(d).find((f) => f.endsWith('.apk')) - if (apk) return path.join(d, apk) - } - return null + const apks = roots.flatMap((d) => (fs.existsSync(d) ? fs.readdirSync(d).filter((f) => f.endsWith('.apk')).map((f) => path.join(d, f)) : [])) + // the build most recently written wins, as on iOS + return apks.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0] ?? null } function buildToolsDirs() { diff --git a/action/cli/lib/device.mjs b/action/cli/lib/device.mjs index a937e75..97bd12c 100644 --- a/action/cli/lib/device.mjs +++ b/action/cli/lib/device.mjs @@ -63,24 +63,83 @@ export async function waitFor(promise, ms, label) { // with capture helpers; call session.close() at the end. export async function openSession({ projectDir, config, scheme, platform = 'ios' }) { const driver = driverFor(platform) + const nativescript = config.runtime === 'nativescript' const { id, name } = await driver.ensureBooted(config) driver.freezeStatusBar(id) const appPath = config.appPath ?? driver.findBuiltApp(projectDir) if (!appPath) { - throw new Error(platform === 'android' - ? 'no built dev client found under android/app/build/outputs/apk — build it first (expo run:android --no-bundler) or pass app_path' - : 'no built dev client found under ios/build — build it first (expo run:ios --no-bundler)') + const where = nativescript + ? (platform === 'android' ? 'platforms/android/app/build/outputs/apk — build it first (ns build android)' : 'platforms/ios/build — build it first (ns build ios)') + : (platform === 'android' ? 'android/app/build/outputs/apk — build it first (expo run:android --no-bundler)' : 'ios/build — build it first (expo run:ios --no-bundler)') + throw new Error(`no built app found under ${where}, or pass app_path`) } const appId = config.appId ?? driver.appIdOf(appPath) + if (nativescript) { + const devServer = bundleDevServer(appPath, platform) + if (devServer) { + throw new Error( + `${path.basename(appPath)} is a \`ns debug\` build: its JavaScript loads from the Vite dev server at ${devServer}, ` + + `so it only runs while that server is up. Build with \`ns build ${platform}\` and pass that .app/.apk (or leave appPath unset so it is discovered under platforms/).` + ) + } + } driver.installApp(id, appPath) driver.grantPrivacy(id, appId) - driver.muteDevMenu(id, appId) - driver.approveScheme(id, scheme, appId) + if (!nativescript) driver.muteDevMenu(id, appId) + if (scheme) driver.approveScheme(id, scheme, appId) await sleep(3000) // let the launcher settle (iOS resprings SpringBoard above) // resolve the OCR backend now — compiling the Vision helper lazily inside the // connect loop starves a small runner while Metro bundles, and simctl openurl // then times out ocrAvailable() + const metro = nativescript ? null : await connectDevClient({ projectDir, config, scheme, platform, driver, id, appId }) + if (nativescript) { + // the bundle ships inside the app, so launching it is the whole boot + driver.terminate(id, appId) + await sleep(800) + driver.launch(id, appId) + } + await sleep(config.waits.boot) + log(`session ready: ${appId} on ${name} (${id})${metro ? `, Metro :${config.metroPort}` : ', bundled JS'}`) + let firstVisit = true + const session = { + platform, driver, id, udid: id, appId, bundleId: appId, scheme, config, + deviceName: name ?? config.device, + screenshot(outPath) { return driver.screenshot(id, outPath) }, + async visit(url, outPath, waitMs) { + try { driver.openUrl(id, url, appId) } catch { await sleep(2000); driver.openUrl(id, url, appId) } // one retry for transient timeouts + await sleep(waitMs ?? config.waits.transition) + // dev builds often show a one-off toast right after the bundle loads; + // give the very first capture extra time to settle + if (firstVisit) { await sleep(config.waits.settle ?? 6000); firstVisit = false } + driver.screenshot(id, outPath) + return outPath + }, + async relaunch() { + driver.terminate(id, appId); await sleep(800); driver.launch(id, appId); await sleep(4000) + firstVisit = true // dev builds re-show their load-time toast after a relaunch + }, + close() { metro?.stop() }, + } + return session +} + +// A NativeScript Vite dev build ships a stub bundle that imports every module +// over HTTP from the dev server; headless, it dies on the first import with +// the home screen as its only capture. Returns that server's origin, or null +// for a self-contained bundle. +function bundleDevServer(appPath, platform) { + let src = null + try { + if (platform === 'android') src = sh('unzip', ['-p', appPath, 'assets/app/bundle.mjs'], { maxBuffer: 256 * 1024 * 1024 }) + else src = fs.readFileSync(path.join(appPath, 'app', 'bundle.mjs'), 'utf8') + } catch { return null } + return src.match(/https?:\/\/[\w.-]+:\d+(?=\/ns\/)/)?.[0] ?? null +} + +// Start Metro and steer the dev client onto it; resolves with the Metro +// handle once the first bundle has been served. +async function connectDevClient({ projectDir, config, scheme, platform, driver, id, appId }) { const metro = startMetro(projectDir, config.metroPort) await waitFor(metro.ready, 120000, 'Metro to start') // the emulator's localhost is not the host's — open the tunnel before the @@ -126,27 +185,5 @@ export async function openSession({ projectDir, config, scheme, platform = 'ios' metro.stop() throw new Error(`timed out waiting for first JS bundle (${platform})`) } - await sleep(config.waits.boot) - log(`session ready: ${appId} on ${name} (${id}), Metro :${config.metroPort}`) - let firstVisit = true - const session = { - platform, driver, id, udid: id, appId, bundleId: appId, scheme, config, - deviceName: name ?? config.device, - screenshot(outPath) { return driver.screenshot(id, outPath) }, - async visit(url, outPath, waitMs) { - try { driver.openUrl(id, url, appId) } catch { await sleep(2000); driver.openUrl(id, url, appId) } // one retry for transient timeouts - await sleep(waitMs ?? config.waits.transition) - // dev builds often show a one-off toast right after the bundle loads; - // give the very first capture extra time to settle - if (firstVisit) { await sleep(config.waits.settle ?? 6000); firstVisit = false } - driver.screenshot(id, outPath) - return outPath - }, - async relaunch() { - driver.terminate(id, appId); await sleep(800); driver.launch(id, appId); await sleep(4000) - firstVisit = true // dev builds re-show their load-time toast after a relaunch - }, - close() { metro.stop() }, - } - return session + return metro } diff --git a/action/cli/lib/shot.mjs b/action/cli/lib/shot.mjs index ce74159..e5733a5 100644 --- a/action/cli/lib/shot.mjs +++ b/action/cli/lib/shot.mjs @@ -1,8 +1,10 @@ // Render the PR-comment image: serve the two bundles from a throwaway local -// server (CORS-open; https viewer → http://localhost is allowed by Chrome), -// open the hosted visualiser in headless Chrome with ?shot, wait for the -// viewer's readiness flag, screenshot. No vite build on the runner, and it -// works for private repos too — the bundles never leave the machine. +// server (CORS-open), open the hosted visualiser in headless Chrome with +// ?shot, wait for the viewer's readiness flag, screenshot. No vite build on +// the runner, and it works for private repos too — the bundles never leave +// the machine. Chrome's local network access check would refuse the https +// viewer a fetch from http://localhost, so it is switched off for this one +// headless browser. import fs from 'node:fs' import http from 'node:http' import path from 'node:path' @@ -36,7 +38,7 @@ function serveFiles(files) { }) } -export async function takeShot({ mapFile, changesFile, out, viewer = 'https://app.screenmap.dev', width = 1500, height = 940 }) { +export async function takeShot({ mapFile, changesFile, out, mode, viewer = 'https://app.screenmap.dev', width = 1500, height = 940 }) { const chrome = findChrome() if (!chrome) throw new Error('no Chrome found for the comment image (set CHROME_PATH)') const files = { 'base.scrmap': mapFile } @@ -44,12 +46,14 @@ export async function takeShot({ mapFile, changesFile, out, viewer = 'https://ap for (const f of Object.values(files)) if (!fs.existsSync(f)) throw new Error(`missing bundle: ${f}`) const server = await serveFiles(files) const { default: puppeteer } = await import('puppeteer-core') - const browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-sandbox', '--force-color-profile=srgb'] }) + const browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-sandbox', '--force-color-profile=srgb', '--disable-features=LocalNetworkAccessChecks,PrivateNetworkAccessRespectPreflightResults,PrivateNetworkAccessSendPreflights'] }) try { const params = new URLSearchParams() params.set('map', `http://localhost:${server.port}/base.scrmap`) if (changesFile) params.set('changes', `http://localhost:${server.port}/changes.diff.scrmap`) - params.set('shot', changesFile ? 'changed' : 'all') + // captured: only screens with a good capture, for a map the deterministic + // lane reached only part of + params.set('shot', mode ?? (changesFile ? 'changed' : 'all')) const url = `${viewer}/?${params}` const page = await browser.newPage() await page.setViewport({ width, height, deviceScaleFactor: 2 }) diff --git a/action/cli/lib/sim.mjs b/action/cli/lib/sim.mjs index 7b49db1..e20318a 100644 --- a/action/cli/lib/sim.mjs +++ b/action/cli/lib/sim.mjs @@ -77,17 +77,23 @@ export function freezeStatusBar(udid) { '--wifiBars', '3', '--cellularMode', 'active', '--cellularBars', '4', '--batteryState', 'charged', '--batteryLevel', '100']) } +// Expo's `run:ios` output and NativeScript's `ns build ios`; a project only +// ever has one of the two trees. export function findBuiltApp(projectDir) { const dirs = [ path.join(projectDir, 'ios', 'build', 'Build', 'Products', 'Debug-iphonesimulator'), path.join(projectDir, 'ios', 'build', 'Build', 'Products', 'Release-iphonesimulator'), + path.join(projectDir, 'platforms', 'ios', 'build', 'Debug-iphonesimulator'), + path.join(projectDir, 'platforms', 'ios', 'build', 'Release-iphonesimulator'), ] - for (const d of dirs) { - if (!fs.existsSync(d)) continue - const app = fs.readdirSync(d).find((f) => f.endsWith('.app')) - if (app) return path.join(d, app) - } - return null + return newestOf(dirs.flatMap((d) => (fs.existsSync(d) ? fs.readdirSync(d).filter((f) => f.endsWith('.app')).map((f) => path.join(d, f)) : []))) +} + +// The build most recently written, wherever it sits: a project can hold a +// Debug and a Release build of the same app, and the one just built is the +// one meant. +function newestOf(paths) { + return paths.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0] ?? null } export function appIdOf(appPath) { diff --git a/action/cli/lib/util.mjs b/action/cli/lib/util.mjs index 7f446c7..eb3aba2 100644 --- a/action/cli/lib/util.mjs +++ b/action/cli/lib/util.mjs @@ -87,6 +87,15 @@ const PLATFORM_DEFAULTS = { android: { device: null, appId: null, appPath: null }, } +// How the app's JavaScript reaches the device. An Expo dev client loads its +// bundle from Metro over the network; a NativeScript app ships the bundle +// inside the .app/.apk, so launching it is the whole boot. +export const RUNTIMES = ['expo', 'nativescript'] +export function detectRuntime(projectDir) { + const ns = ['nativescript.config.ts', 'nativescript.config.js', 'nativescript.config.mjs', 'nativescript.config.cjs', 'nsconfig.json'] + return ns.some((f) => fs.existsSync(path.join(projectDir, f))) ? 'nativescript' : 'expo' +} + export function loadConfig(projectDir) { const base = { scheme: null, bundleId: null, appPath: null, device: null, metroPort: 8081, @@ -120,6 +129,8 @@ export function loadConfig(projectDir) { routes: { ...defaults.routes, ...(user.routes ?? {}) }, } if (process.env.AGENT_MAX_SCREENS) merged.agent.maxScreens = Number(process.env.AGENT_MAX_SCREENS) || merged.agent.maxScreens + merged.runtime = user.runtime ?? detectRuntime(projectDir) + if (!RUNTIMES.includes(merged.runtime)) throw new Error(`unknown runtime "${merged.runtime}" — expected ${RUNTIMES.join(' | ')}`) // platforms: env wins, then config.platforms, then the legacy single-platform // default. An unknown name is a typo worth failing on rather than silently @@ -163,7 +174,7 @@ export function platformConfig(config, platform) { // plenty of screens outside their linking config; falling back to the app root // would capture the home screen and file it under this route's name. export function deepLinkFor(scheme, route, params = {}) { - if (!route.urlPath) return null + if (!route.urlPath || !scheme) return null let p = route.urlPath for (const name of route.params ?? []) { const v = params[`${route.id}.${name}`] ?? params[name] ?? '1' diff --git a/action/cli/screenmap-ci.mjs b/action/cli/screenmap-ci.mjs index a5e903d..7d5ddbb 100755 --- a/action/cli/screenmap-ci.mjs +++ b/action/cli/screenmap-ci.mjs @@ -17,6 +17,8 @@ // (EAS: reuse-by-fingerprint or build) // screenmap-ci merge --inputs ios=a.scrmap,android=b.scrmap --out combined.scrmap // fold per-platform baselines into one multi-platform map +// screenmap-ci shot --map [--changes ] [--out ] [--mode all|captured|changed] [--viewer ] +// render the map in the viewer through headless Chrome // // baseline and pr capture on every platform in config.platforms (default // ["ios"]); --platform narrows a run to one of them, which is how the @@ -26,7 +28,7 @@ // simulator or emulator. See docs/ci.md. import fs from 'node:fs' import path from 'node:path' -import { parseArgs, loadConfig, platformConfig, readJson, writeJson, ensureDir, exists, log, sh, deepLinkFor } from './lib/util.mjs' +import { parseArgs, loadConfig, platformConfig, readJson, writeJson, ensureDir, exists, log, sh, sleep, deepLinkFor } from './lib/util.mjs' import { openSession } from './lib/device.mjs' import { readBaseline, parseRoutes, computeSuspects, packBaseline, packDiff, downscaleAll, baselineSide, platformsIn } from './lib/bundle.mjs' import { loadFlows, replayFlow, verifyLanding, verifyDeepLink } from './lib/replay.mjs' @@ -89,7 +91,22 @@ async function captureRoutes({ project, config, scheme, session, routes, flows, await session.relaunch() } } - if (!done && !deepLinkFor(scheme, r, config.params)) { + if (!done && r.urlPath === '/' && !scheme) { + // An app with no URL scheme still has one screen a deterministic run can + // reach: whatever launching it shows. + try { + await session.relaunch() + await sleep(config.waits.boot) + session.screenshot(path.join(outDir, `${r.slug}.png`)) + result.deeplink.push(r.id) + log(`captured ${r.id} by launching the app (no URL scheme)`) + if (!f) result.unflowed.push(r) + } catch (e) { + log(`launch capture failed for ${r.id}: ${e.message}`) + result.failed.push(r.id) + if (!f) result.unflowed.push(r) + } + } else if (!done && !deepLinkFor(scheme, r, config.params)) { // No URL means no deep link. Hand it to the agent, which reaches it by // tapping, rather than visiting the app root and filing the home screen // under this route's name. @@ -190,8 +207,8 @@ async function baseline() { const work = path.join(project, '.screenmap', 'out', 'ci', 'baseline') fs.rmSync(work, { recursive: true, force: true }); ensureDir(work) const graph = parseRoutes(project, path.join(work, 'graph.json')) - const scheme = config.scheme ?? graph.scheme - if (!scheme) throw new Error('no deep-link scheme: set scheme in .screenmap/config.json') + const scheme = config.scheme ?? graph.scheme ?? null + if (!scheme) log('no URL scheme in the app or .screenmap/config.json — only the root screen, committed flows and the agent can capture anything') const commit = opts.commit ?? git(['rev-parse', 'HEAD'], project) const ref = opts.ref ?? git(['rev-parse', '--abbrev-ref', 'HEAD'], project) const appName = config.appName ?? graph.appName ?? path.basename(project) @@ -712,7 +729,7 @@ async function flowsPr() { async function shot() { const { takeShot } = await import('./lib/shot.mjs') const out = path.resolve(opts.out ?? 'screenmap-shot.png') - await takeShot({ mapFile: path.resolve(opts.map), changesFile: opts.changes ? path.resolve(opts.changes) : null, out, viewer: opts.viewer || undefined }) + await takeShot({ mapFile: path.resolve(opts.map), changesFile: opts.changes ? path.resolve(opts.changes) : null, out, mode: opts.mode || undefined, viewer: opts.viewer || undefined }) console.log(JSON.stringify({ shot: out })) } diff --git a/action/templates/config.json b/action/templates/config.json index 5e67df6..9aeb671 100644 --- a/action/templates/config.json +++ b/action/templates/config.json @@ -16,6 +16,11 @@ }, "waits": { "transition": 2500, "network": 6000, "boot": 15000 }, "suspects": { "broadCap": 8 }, + "routes": { + "$comment": "provider pins the route provider (expo-router | react-navigation | nativescript | custom) when detection is wrong; auto detects. links is for NativeScript apps, whose deep links are whatever the app's own URL handler opens: route id -> path after the scheme, true for the route's own URL, \"*\" as the default for routes not listed. Everything without a link is reached by tapping.", + "provider": "auto", + "links": { "talk/today": "today", "settings": true } + }, "agent": { "$comment": "provider: claude (default) | codex | gemini | opencode; or command: a bash template run instead ({promptFile} / $SCREENMAP_PROMPT_FILE carry the task), with keyEnv naming the env var it needs. scan overrides the effort preset: unflowed | params | all", "enabled": true, diff --git a/apps/visualiser/src/components/Graph.jsx b/apps/visualiser/src/components/Graph.jsx index 2e69c99..2e3829a 100644 --- a/apps/visualiser/src/components/Graph.jsx +++ b/apps/visualiser/src/components/Graph.jsx @@ -15,8 +15,18 @@ const nodeTypes = { screen: ScreenNode } // ?shot — headless-screenshot mode: no chrome, no comparator flip (head side // frozen), viewport fitted to the changed nodes; window.__screenmapShotReady // flips when the frame is worth capturing. Used by screenmap-ci to render the -// PR-comment image. -const SHOT = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('shot') +// PR-comment image. ?shot=captured also drops every screen without a good +// capture, so a map whose deterministic lane reached only part of the app +// shows the screens it has rather than a wall of placeholders. +const SHOT_PARAM = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('shot') : null +const SHOT = SHOT_PARAM !== null +const SHOT_CAPTURED = SHOT_PARAM === 'captured' + +function capturedOnly(map) { + const nodes = map.nodes.filter((n) => n.capture?.screenshot && (n.capture.status ?? 'ok') === 'ok') + const ids = new Set(nodes.map((n) => n.id)) + return { ...map, nodes, edges: map.edges.filter((e) => ids.has(e.from) && ids.has(e.to)) } +} function hueFor(group) { let h = 0 @@ -34,7 +44,8 @@ function statusBadge(node) { const HIDDEN_STEPS = ['wait', 'screenshot'] export default function Graph({ bundle, mode, setMode, hasChanges, overlaid, platforms, platform, setPlatform, onOpenBuffer, onCloseChanges }) { - const { manifest, map, images, diff } = bundle + const { manifest, map: fullMap, images, diff } = bundle + const map = useMemo(() => (SHOT_CAPTURED ? capturedOnly(fullMap) : fullMap), [fullMap]) const diffMode = mode === 'changes' && !!diff const [positions, setPositions] = useState(null) const [selectedFlow, setSelectedFlow] = useState(null) diff --git a/apps/visualiser/src/lib/loadBundle.js b/apps/visualiser/src/lib/loadBundle.js index 8c4e602..74cdb5f 100644 --- a/apps/visualiser/src/lib/loadBundle.js +++ b/apps/visualiser/src/lib/loadBundle.js @@ -250,7 +250,8 @@ function matcherFor(urlPath) { // it carries an explicit `screen` field — how interactive flows record that a // tap/swipe navigated somewhere. export function flowResolution(map) { - const matchers = map.nodes.map((n) => ({ id: n.id, re: matcherFor(n.urlPath) })) + // a navigation-only screen has no URL for an open_url step to land on + const matchers = map.nodes.filter((n) => n.urlPath).map((n) => ({ id: n.id, re: matcherFor(n.urlPath) })) const nodeIds = new Set(map.nodes.map((n) => n.id)) const resolve = (url) => { let p = url.replace(/^[a-z+.-]+:\/\//i, '') diff --git a/docs/route-providers.md b/docs/route-providers.md index df290bc..9f4afcd 100644 --- a/docs/route-providers.md +++ b/docs/route-providers.md @@ -15,6 +15,7 @@ plugins/screenmap/skills/screenmap/scripts/ providers/ expo-router.mjs react-navigation.mjs + nativescript.mjs custom.mjs ``` @@ -24,6 +25,7 @@ Shipped providers: |---|---|---| | `expo-router` | the `app/` route tree | `deep-link` — the file path *is* the URL | | `react-navigation` | `` registrations + the linking config | `mixed` — a screen has a URL only if the linking config gives it one | +| `nativescript` | Angular Router `Routes` arrays; Core XML pages + `Frame.navigate()`; or, for Octane, React, Vue, Svelte and Solid, the components the framework mounts, pushes, presents, hosts in `` tabs or registers in a route table | `mixed` — a screen has a URL only if `routes.links` in `.screenmap/config.json` gives it one | | `custom` | a command you supply | `unknown` | ## Choosing a provider @@ -41,6 +43,7 @@ node scripts/parse-routes.mjs . --list-providers ``` 0.95 expo-router app/ contains a _layout route; expo-router in package.json 0.00 react-navigation expo-router also present — deferring to it +0.00 nativescript no nativescript.config.* and no @nativescript/core dependency 0.00 custom opt-in only: set routes.provider = "custom" ``` @@ -108,6 +111,84 @@ and filing the home screen under the route's name, seeds `capture.needsNavigation` in the bundle, and puts the screen on the agent's work queue to be reached by tapping. +### NativeScript: deep links come from the project + +Angular Router gives every screen a URL *inside* the app +(`/talk/(todayTab:today)`), but nothing says which of those a +`myapp://…` link opens: a NativeScript app registers its scheme in +`App_Resources` and maps URLs onto navigation in its own code. So the +`nativescript` provider starts every route navigation-only and reads the map +from `.screenmap/config.json`: + +```jsonc +{ + "routes": { + "links": { + "talk/today": "today", // myapp://today opens this route + "chatbot/chat": "ask-mae/:id", // params in the link join the route's own + "settings": true, // the route's own URL is the deep link + "*": false // default for routes not listed (false = navigation-only) + } + } +} +``` + +Keys are route ids (the Angular path without the leading slash; the title +with outlet notation also works). `"*": true` says the app's handler mirrors +the router, so every route deep-links by its own URL. The app id, scheme and +name come from `nativescript.config.ts`, `App_Resources/iOS/Info.plist` +(resolving `${BUNDLE_IDENTIFIER}`-style xcconfig variables) and +`AndroidManifest.xml`; `ctx.appConfig()` does that reading, so a provider for +another NativeScript flavour gets it for free. + +The Angular flavour follows `loadChildren` into lazy route files, named +outlets, `redirectTo` aliases, enum-valued paths and barrel re-exports; a +route with `children` becomes a layout (`Tabs` when its children sit in two or +more named outlets, else `Stack`), and links aimed at it resolve to the child +the router would land on. Edges come from `navigate([...])`, +`navigateByUrl('…')` and `nsRouterLink`, with `relativeTo` honoured. Modals and +material bottom sheets opened from a screen become `ns-modal` and +`bottom-sheet` state hints carrying the component name. The Core flavour reads +`` XML files as routes and `navigate({ moduleName })` / `showModal()` +calls as edges and hints. + +The component flavours (Octane, React NativeScript, Vue, Svelte) have no route +table at all, so a screen is a component the framework **mounts** as a root, +**pushes**, or **presents** as a modal, and each flavour is one row of +spellings for those three calls: + +| flavour | mount | push | modal | +|---|---|---|---| +| `octane` | `renderNativeScriptApp(view, X)` | — | a second root rendered into a view the same module `showModal`s | +| `react` | `ReactNativeScript.start(React.createElement(X))` / `start()` | — | — | +| `vue` | `createApp(X)`, `render: h => h(X)` | `$navigateTo(X)` | `$showModal(X)` | +| `svelte` | `svelteNative(X)` / `svelteNativeNoFrame(X)` | `navigate({ page: X })` | `showModal({ page: X })` | +| `solid` | `startSolidApp({ root: X })`, `render(() => , view)` | `` + `navigate('N')` (solid-navigation) | a `render` into a view the same module `showModal`s | + +Two rules apply to every flavour on top of the table. A component that is the +sole child of a `` is a screen hosted by the component around it +(`layoutDir` is the host, `navigator` is `Tabs` when the host is a tab view), +which is how the tab bars in Vue and Solid apps come out. And a mounted root +whose file declares a route table is the router shell: a layout, not a screen, +with its `initialRouteName` as the app root. + +The mount in the entry module (`package.json` `main`) is the app root and gets +`urlPath: "/"`; every other component is navigation-only until `routes.links` +says otherwise. In the Angular flavour the same `/` goes to whatever the empty +path resolves to (a `''` container's first child, or a redirect's target), +since that is the screen a launch shows. Edges come from the same calls found in a screen's own file or +one import hop out, so a helper like Octane's `openSettings(host)` links the +screen that imports it to the sheet it renders. A `` in a screen and +`menu=` / `contextMenu=` props become `drawer` and `native-menu` state hints. +Angular, Octane, Vue and Solid are validated against real apps; React and +Svelte against fixtures only. Other renderers are not recognised yet — +prototype those as a custom provider. + +An app that registers no URL scheme at all (many single-screen apps) parses +fine: `scheme` is null, `deepLinkTemplates.devBuild` is null, and the CI lane +captures the root by launching the app and everything else through flows or +the agent. + ## Writing a provider Two exported functions and a `meta`: @@ -136,10 +217,15 @@ export function parse(ctx) { | `resolveImport(spec, fromFile)` | one import specifier → absolute path, alias-aware | | `firstPartyImports(src, fromRel)` | every first-party import of a file, as repo-relative paths | | `pathAliases()` | parsed `tsconfig.json` `compilerOptions.paths` (JSONC-tolerant) | -| `appConfig()` | `{ name, scheme, slug }` from `app.json` or `app.config.*` | +| `appConfig()` | `{ name, scheme, slug }` from `app.json` or `app.config.*`, else from `nativescript.config.*` + `App_Resources` | +| `nativescriptConfig()` | `{ id, appPath, appResourcesPath }` from `nativescript.config.*`, or null | | `deps()`, `packageJson()` | | | `routeMatcher(urlPath)` | pattern → RegExp, understands `[param]` and `:param` | +A provider may also export `deepLinkTemplates(scheme)` to replace the graph's +`deepLinkTemplates` block; the default advertises an Expo Go URL, which means +nothing for a framework Expo Go cannot host. + Register it in `registry.mjs`, add a fixture under `fixtures/`, and add a line to `fixtures/run-tests.mjs`. @@ -173,4 +259,6 @@ node fixtures/run-tests.mjs --update # accept an intended change Each fixture pins both the provider detection picks and the entire graph, so a change that silently re-routes a project to a different provider, or drops a -route, fails here rather than in someone's capture run. +route, fails here rather than in someone's capture run. A fixture may commit a +`.screenmap/config.json` (the NativeScript Angular one does, for `routes.links`); +only `.screenmap/out/` is removed after a run. diff --git a/fixtures/ns-angular-demo-app/.screenmap/config.json b/fixtures/ns-angular-demo-app/.screenmap/config.json new file mode 100644 index 0000000..931d421 --- /dev/null +++ b/fixtures/ns-angular-demo-app/.screenmap/config.json @@ -0,0 +1,10 @@ +{ + "routes": { + "links": { + "home/feed": "feed", + "details/:id": "item/:id", + "settings": true, + "login": false + } + } +} diff --git a/fixtures/ns-angular-demo-app/App_Resources/Android/src/main/AndroidManifest.xml b/fixtures/ns-angular-demo-app/App_Resources/Android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5139727 --- /dev/null +++ b/fixtures/ns-angular-demo-app/App_Resources/Android/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/fixtures/ns-angular-demo-app/App_Resources/iOS/Info.plist b/fixtures/ns-angular-demo-app/App_Resources/iOS/Info.plist new file mode 100644 index 0000000..31b6545 --- /dev/null +++ b/fixtures/ns-angular-demo-app/App_Resources/iOS/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDisplayName + ${BUNDLE_DISPLAY_NAME} + CFBundleName + ${PRODUCT_NAME} + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + com.googleusercontent.apps.123456-abcdef + ${BUNDLE_IDENTIFIER} + + + + UILaunchStoryboardName + LaunchScreen + + diff --git a/fixtures/ns-angular-demo-app/App_Resources/iOS/build.xcconfig b/fixtures/ns-angular-demo-app/App_Resources/iOS/build.xcconfig new file mode 100644 index 0000000..caee98c --- /dev/null +++ b/fixtures/ns-angular-demo-app/App_Resources/iOS/build.xcconfig @@ -0,0 +1,4 @@ +// Values the Info.plist refers to by name. +BUNDLE_DISPLAY_NAME = NS Demo; +BUNDLE_IDENTIFIER = org.screenmap.nsdemo; +IPHONEOS_DEPLOYMENT_TARGET = 17.0; diff --git a/fixtures/ns-angular-demo-app/expected-graph.json b/fixtures/ns-angular-demo-app/expected-graph.json new file mode 100644 index 0000000..b531885 --- /dev/null +++ b/fixtures/ns-angular-demo-app/expected-graph.json @@ -0,0 +1,216 @@ +{ + "appName": "NS Demo", + "scheme": "org.screenmap.nsdemo", + "deepLinkTemplates": { + "devBuild": "org.screenmap.nsdemo://" + }, + "mode": "nativescript", + "flavor": "angular", + "appPath": "src", + "routesFile": "src/app/app.routes.ts", + "screensResolved": 7, + "layouts": [ + { + "file": "src/app/app.routes.ts", + "dir": "", + "navigator": "Stack" + }, + { + "file": "src/app/home/home.component.ts", + "dir": "home", + "navigator": "Tabs" + } + ], + "routes": [ + { + "id": "login", + "file": "src/app/login/login.component.ts", + "urlPath": null, + "title": "/login", + "reach": "navigation-only", + "slug": "login", + "params": [], + "navigator": "Stack", + "layoutDir": "", + "presentation": null, + "stateHints": [] + }, + { + "id": "home/feed", + "file": "src/app/home/feed/feed.component.ts", + "urlPath": "/feed", + "title": "/home/(feedTab:feed)", + "reach": "deep-link", + "slug": "home_feed", + "params": [], + "navigator": "Tabs", + "layoutDir": "home", + "presentation": null, + "stateHints": [] + }, + { + "id": "home/search", + "file": "src/app/home/search/search.component.ts", + "urlPath": null, + "title": "/home/(searchTab:search)", + "reach": "navigation-only", + "slug": "home_search", + "params": [], + "navigator": "Tabs", + "layoutDir": "home", + "presentation": null, + "stateHints": [ + { + "type": "bottom-sheet", + "lib": "material-bottomsheet", + "snapPoints": null, + "component": "FiltersSheetComponent" + } + ] + }, + { + "id": "details/:id", + "file": "src/app/details/details.component.ts", + "urlPath": "/item/:id", + "title": "/details/:id", + "reach": "deep-link", + "slug": "details_id", + "params": [ + "id" + ], + "navigator": "Stack", + "layoutDir": "", + "presentation": null, + "stateHints": [ + { + "type": "ns-modal", + "component": "ShareDialogComponent" + } + ] + }, + { + "id": "settings", + "file": "src/app/settings/settings.component.ts", + "urlPath": "/settings", + "title": "/settings", + "reach": "deep-link", + "slug": "settings", + "params": [], + "navigator": "Stack", + "layoutDir": "", + "presentation": null, + "stateHints": [] + }, + { + "id": "compose", + "file": "src/app/compose/compose.component.ts", + "urlPath": null, + "title": "/compose", + "reach": "navigation-only", + "slug": "compose", + "params": [], + "navigator": "Stack", + "layoutDir": "", + "presentation": null, + "stateHints": [] + }, + { + "id": "**", + "file": "src/app/not-found/not-found.component.ts", + "urlPath": null, + "title": "/**", + "reach": "navigation-only", + "slug": "wildcard", + "params": [], + "navigator": "Stack", + "layoutDir": "", + "presentation": null, + "stateHints": [] + } + ], + "edges": [ + { + "from": "login", + "to": "home/feed", + "raw": "['/home', { outlets: { feedTab: ['feed'] } }]", + "target": "/home/feed" + }, + { + "from": "home/feed", + "to": "settings", + "raw": "'/settings'", + "target": "/settings" + }, + { + "from": "home/feed", + "to": "compose", + "raw": "['/compose']", + "target": "/compose" + }, + { + "from": "home/feed", + "to": "details/:id", + "raw": "['/details', item.id]", + "target": "/details/:id" + }, + { + "from": "home/search", + "to": "details/:id", + "raw": "['/details', id]", + "target": "/details/:id" + }, + { + "from": "details/:id", + "to": "details/:id", + "raw": "[`/details/${this.id + 1}`]", + "target": "/details/:id" + }, + { + "from": "details/:id", + "to": "settings", + "raw": "['settings']", + "target": "/settings" + }, + { + "from": "settings", + "to": "settings", + "raw": "['/account']", + "target": "/settings" + }, + { + "from": "settings", + "to": "login", + "raw": "'/login'", + "target": "/login" + }, + { + "from": "settings", + "to": "home/search", + "raw": "['/home', { outlets: { searchTab: ['search'] } }]", + "target": "/home/search" + }, + { + "from": "compose", + "to": "home/feed", + "raw": "['/home']", + "target": "/home/feed" + }, + { + "from": "**", + "to": "home/feed", + "raw": "/home", + "target": "/home/feed" + } + ], + "summary": { + "mode": "nativescript", + "routes": 7, + "layouts": 2, + "edges": 12, + "unresolvedEdges": 0, + "routesWithStateHints": 2, + "routesNeedingParams": 1, + "navigationOnlyRoutes": 4, + "orphanRoutes": 1 + } +} diff --git a/fixtures/ns-angular-demo-app/nativescript.config.ts b/fixtures/ns-angular-demo-app/nativescript.config.ts new file mode 100644 index 0000000..c4f4cc6 --- /dev/null +++ b/fixtures/ns-angular-demo-app/nativescript.config.ts @@ -0,0 +1,8 @@ +import { NativeScriptConfig } from '@nativescript/core'; + +export default { + id: 'org.screenmap.nsdemo', + appPath: 'src', + appResourcesPath: 'App_Resources', + ios: { discardUncaughtJsExceptions: false } +} as NativeScriptConfig; diff --git a/fixtures/ns-angular-demo-app/package.json b/fixtures/ns-angular-demo-app/package.json new file mode 100644 index 0000000..c957689 --- /dev/null +++ b/fixtures/ns-angular-demo-app/package.json @@ -0,0 +1,16 @@ +{ + "name": "ns-angular-demo", + "main": "./src/main.ts", + "private": true, + "dependencies": { + "@angular/core": "~22.0.0", + "@angular/router": "~22.0.0", + "@nativescript/angular": "~22.0.0", + "@nativescript/core": "~9.1.0", + "@nativescript-community/ui-material-bottomsheet": "~7.2.0" + }, + "devDependencies": { + "@nativescript/ios": "~9.1.0", + "typescript": "~5.9.0" + } +} diff --git a/fixtures/ns-angular-demo-app/src/app/app.component.ts b/fixtures/ns-angular-demo-app/src/app/app.component.ts new file mode 100644 index 0000000..3a3ab11 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/app.component.ts @@ -0,0 +1,10 @@ +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { PageRouterOutlet } from '@nativescript/angular'; + +@Component({ + selector: 'ns-app', + template: '', + imports: [PageRouterOutlet], + schemas: [NO_ERRORS_SCHEMA] +}) +export class AppComponent {} diff --git a/fixtures/ns-angular-demo-app/src/app/app.routes.ts b/fixtures/ns-angular-demo-app/src/app/app.routes.ts new file mode 100644 index 0000000..3421660 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/app.routes.ts @@ -0,0 +1,37 @@ +import { inject } from '@angular/core'; +import { Routes } from '@angular/router'; +import { DetailsComponent } from './details'; +import { SettingsComponent } from '~/app/settings/settings.component'; +import { Screens, SETTINGS_PATH } from './common/screens.enum'; +import { AuthGuard } from './common/auth.guard'; + +export const routes: Routes = [ + { + path: 'login', + loadComponent: () => import('./login/login.component').then(m => m.LoginComponent) + }, + { + path: 'home', + loadChildren: () => import('./home/home.routes').then(m => m.routes), + canActivate: [() => inject(AuthGuard).canActivate()] + }, + // Detail screens take the item id in the URL. + { + path: 'details/:id', + component: DetailsComponent + }, + { path: SETTINGS_PATH, component: SettingsComponent }, + { + path: 'account', + redirectTo: 'settings', + pathMatch: 'full' + }, + { + path: Screens.Compose, + loadComponent: () => import('./compose/compose.component').then((m) => m.ComposeComponent) + }, + { + path: '**', + loadComponent: () => import('./not-found/not-found.component').then(m => m.NotFoundComponent) + } +]; diff --git a/fixtures/ns-angular-demo-app/src/app/common/auth.guard.ts b/fixtures/ns-angular-demo-app/src/app/common/auth.guard.ts new file mode 100644 index 0000000..a9e42ca --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/common/auth.guard.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class AuthGuard { + canActivate(): boolean { + return true; + } +} diff --git a/fixtures/ns-angular-demo-app/src/app/common/screens.enum.ts b/fixtures/ns-angular-demo-app/src/app/common/screens.enum.ts new file mode 100644 index 0000000..ccc6cc1 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/common/screens.enum.ts @@ -0,0 +1,6 @@ +export enum Screens { + Search = 'search', + Compose = 'compose' +} + +export const SETTINGS_PATH = 'settings'; diff --git a/fixtures/ns-angular-demo-app/src/app/compose/compose.component.ts b/fixtures/ns-angular-demo-app/src/app/compose/compose.component.ts new file mode 100644 index 0000000..4590766 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/compose/compose.component.ts @@ -0,0 +1,16 @@ +import { Component, NO_ERRORS_SCHEMA, inject } from '@angular/core'; +import { NativeScriptCommonModule, RouterExtensions } from '@nativescript/angular'; + +@Component({ + selector: 'ns-compose', + template: '', + imports: [NativeScriptCommonModule], + schemas: [NO_ERRORS_SCHEMA] +}) +export class ComposeComponent { + private router = inject(RouterExtensions); + + post() { + this.router.navigate(['/home'], { clearHistory: true }); + } +} diff --git a/fixtures/ns-angular-demo-app/src/app/details/details.component.html b/fixtures/ns-angular-demo-app/src/app/details/details.component.html new file mode 100644 index 0000000..3fc8c56 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/details/details.component.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/fixtures/ns-angular-demo-app/src/app/details/details.component.ts b/fixtures/ns-angular-demo-app/src/app/details/details.component.ts new file mode 100644 index 0000000..f39da76 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/details/details.component.ts @@ -0,0 +1,30 @@ +import { Component, NO_ERRORS_SCHEMA, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { NativeScriptCommonModule, NativeScriptRouterModule, RouterExtensions } from '@nativescript/angular'; +import { NativeDialogService } from '@nativescript/angular'; +import { ShareDialogComponent } from '../share/share-dialog.component'; + +@Component({ + selector: 'ns-details', + templateUrl: './details.component.html', + imports: [NativeScriptCommonModule, NativeScriptRouterModule], + schemas: [NO_ERRORS_SCHEMA] +}) +export class DetailsComponent { + private router = inject(RouterExtensions); + private route = inject(ActivatedRoute); + private nativeDialog = inject(NativeDialogService); + id = Number(this.route.snapshot.params.id); + + share() { + this.nativeDialog.open(ShareDialogComponent, { data: { id: this.id } }); + } + + next() { + this.router.navigate([`/details/${this.id + 1}`]); + } + + settings() { + this.router.navigate(['settings'], { relativeTo: this.route.parent }); + } +} diff --git a/fixtures/ns-angular-demo-app/src/app/details/index.ts b/fixtures/ns-angular-demo-app/src/app/details/index.ts new file mode 100644 index 0000000..0788dcd --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/details/index.ts @@ -0,0 +1 @@ +export * from './details.component'; diff --git a/fixtures/ns-angular-demo-app/src/app/home/feed/feed-item.component.ts b/fixtures/ns-angular-demo-app/src/app/home/feed/feed-item.component.ts new file mode 100644 index 0000000..3cf0ca5 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/home/feed/feed-item.component.ts @@ -0,0 +1,22 @@ +import { Component, Input, NO_ERRORS_SCHEMA, inject } from '@angular/core'; +import { NativeScriptCommonModule, NativeScriptRouterModule, RouterExtensions } from '@nativescript/angular'; + +@Component({ + selector: 'ns-feed-item', + template: ` + + + + + `, + imports: [NativeScriptCommonModule, NativeScriptRouterModule], + schemas: [NO_ERRORS_SCHEMA] +}) +export class FeedItemComponent { + @Input() item: { id: number; title: string }; + private router = inject(RouterExtensions); + + reply() { + this.router.navigate(['/compose'], { queryParams: { replyTo: this.item.id } }); + } +} diff --git a/fixtures/ns-angular-demo-app/src/app/home/feed/feed.component.html b/fixtures/ns-angular-demo-app/src/app/home/feed/feed.component.html new file mode 100644 index 0000000..624feb6 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/home/feed/feed.component.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/fixtures/ns-angular-demo-app/src/app/home/feed/feed.component.ts b/fixtures/ns-angular-demo-app/src/app/home/feed/feed.component.ts new file mode 100644 index 0000000..d10e2e3 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/home/feed/feed.component.ts @@ -0,0 +1,18 @@ +import { Component, NO_ERRORS_SCHEMA, inject } from '@angular/core'; +import { NativeScriptCommonModule, NativeScriptRouterModule, RouterExtensions } from '@nativescript/angular'; +import { FeedItemComponent } from './feed-item.component'; + +@Component({ + selector: 'ns-feed', + templateUrl: './feed.component.html', + imports: [NativeScriptCommonModule, NativeScriptRouterModule, FeedItemComponent], + schemas: [NO_ERRORS_SCHEMA] +}) +export class FeedComponent { + private router = inject(RouterExtensions); + items = [{ id: 1, title: 'First' }, { id: 2, title: 'Second' }]; + + openSettings() { + this.router.router.navigateByUrl('/settings'); + } +} diff --git a/fixtures/ns-angular-demo-app/src/app/home/home.component.html b/fixtures/ns-angular-demo-app/src/app/home/home.component.html new file mode 100644 index 0000000..13d8986 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/home/home.component.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/fixtures/ns-angular-demo-app/src/app/home/home.component.ts b/fixtures/ns-angular-demo-app/src/app/home/home.component.ts new file mode 100644 index 0000000..11ac64c --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/home/home.component.ts @@ -0,0 +1,10 @@ +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { PageRouterOutlet } from '@nativescript/angular'; + +@Component({ + selector: 'ns-home', + templateUrl: './home.component.html', + imports: [PageRouterOutlet], + schemas: [NO_ERRORS_SCHEMA] +}) +export class HomeComponent {} diff --git a/fixtures/ns-angular-demo-app/src/app/home/home.routes.ts b/fixtures/ns-angular-demo-app/src/app/home/home.routes.ts new file mode 100644 index 0000000..d15785b --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/home/home.routes.ts @@ -0,0 +1,27 @@ +import { Routes } from '@angular/router'; +import { HomeComponent } from './home.component'; +import { Screens } from '~/app/common/screens.enum'; + +export const routes: Routes = [ + { + path: '', + component: HomeComponent, + children: [ + { + path: '', + redirectTo: 'feed', + pathMatch: 'full' + }, + { + path: 'feed', + outlet: 'feedTab', + loadComponent: () => import('./feed/feed.component').then(m => m.FeedComponent) + }, + { + path: Screens.Search, + outlet: 'searchTab', + loadComponent: () => import('./search/search.component').then(m => m.SearchComponent) + } + ] + } +]; diff --git a/fixtures/ns-angular-demo-app/src/app/home/search/filters-sheet.component.ts b/fixtures/ns-angular-demo-app/src/app/home/search/filters-sheet.component.ts new file mode 100644 index 0000000..0150858 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/home/search/filters-sheet.component.ts @@ -0,0 +1,10 @@ +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { NativeScriptCommonModule } from '@nativescript/angular'; + +@Component({ + selector: 'ns-filters-sheet', + template: '', + imports: [NativeScriptCommonModule], + schemas: [NO_ERRORS_SCHEMA] +}) +export class FiltersSheetComponent {} diff --git a/fixtures/ns-angular-demo-app/src/app/home/search/search.component.html b/fixtures/ns-angular-demo-app/src/app/home/search/search.component.html new file mode 100644 index 0000000..bcaeebd --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/home/search/search.component.html @@ -0,0 +1,5 @@ + + + + + diff --git a/fixtures/ns-angular-demo-app/src/app/home/search/search.component.ts b/fixtures/ns-angular-demo-app/src/app/home/search/search.component.ts new file mode 100644 index 0000000..7c8ce38 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/home/search/search.component.ts @@ -0,0 +1,23 @@ +import { Component, NO_ERRORS_SCHEMA, inject } from '@angular/core'; +import { NativeScriptCommonModule, RouterExtensions } from '@nativescript/angular'; +import { BottomSheetService } from '@nativescript-community/ui-material-bottomsheet/angular'; +import { FiltersSheetComponent } from './filters-sheet.component'; + +@Component({ + selector: 'ns-search', + templateUrl: './search.component.html', + imports: [NativeScriptCommonModule], + schemas: [NO_ERRORS_SCHEMA] +}) +export class SearchComponent { + private router = inject(RouterExtensions); + private bottomSheet = inject(BottomSheetService); + + openFilters() { + this.bottomSheet.show(FiltersSheetComponent, { dismissOnBackgroundTap: true }); + } + + openResult(id: number) { + this.router.navigate(['/details', id]); + } +} diff --git a/fixtures/ns-angular-demo-app/src/app/login/login.component.html b/fixtures/ns-angular-demo-app/src/app/login/login.component.html new file mode 100644 index 0000000..b913a32 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/login/login.component.html @@ -0,0 +1,5 @@ + + + + + diff --git a/fixtures/ns-angular-demo-app/src/app/login/login.component.ts b/fixtures/ns-angular-demo-app/src/app/login/login.component.ts new file mode 100644 index 0000000..396d8e0 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/login/login.component.ts @@ -0,0 +1,16 @@ +import { Component, NO_ERRORS_SCHEMA, inject } from '@angular/core'; +import { NativeScriptCommonModule, NativeScriptRouterModule, RouterExtensions } from '@nativescript/angular'; + +@Component({ + selector: 'ns-login', + templateUrl: './login.component.html', + imports: [NativeScriptCommonModule, NativeScriptRouterModule], + schemas: [NO_ERRORS_SCHEMA] +}) +export class LoginComponent { + private router = inject(RouterExtensions); + + signIn() { + this.router.navigate(['/home', { outlets: { feedTab: ['feed'] } }], { clearHistory: true, transition: { name: 'fade' } }); + } +} diff --git a/fixtures/ns-angular-demo-app/src/app/not-found/not-found.component.ts b/fixtures/ns-angular-demo-app/src/app/not-found/not-found.component.ts new file mode 100644 index 0000000..b8c4b60 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/not-found/not-found.component.ts @@ -0,0 +1,10 @@ +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { NativeScriptCommonModule, NativeScriptRouterModule } from '@nativescript/angular'; + +@Component({ + selector: 'ns-not-found', + template: '', + imports: [NativeScriptCommonModule, NativeScriptRouterModule], + schemas: [NO_ERRORS_SCHEMA] +}) +export class NotFoundComponent {} diff --git a/fixtures/ns-angular-demo-app/src/app/settings/settings.component.html b/fixtures/ns-angular-demo-app/src/app/settings/settings.component.html new file mode 100644 index 0000000..86be7d6 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/settings/settings.component.html @@ -0,0 +1,5 @@ + + + + + diff --git a/fixtures/ns-angular-demo-app/src/app/settings/settings.component.ts b/fixtures/ns-angular-demo-app/src/app/settings/settings.component.ts new file mode 100644 index 0000000..a625a39 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/settings/settings.component.ts @@ -0,0 +1,24 @@ +import { Component, NO_ERRORS_SCHEMA, inject } from '@angular/core'; +import { NativeScriptCommonModule, RouterExtensions } from '@nativescript/angular'; + +@Component({ + selector: 'ns-settings', + templateUrl: './settings.component.html', + imports: [NativeScriptCommonModule], + schemas: [NO_ERRORS_SCHEMA] +}) +export class SettingsComponent { + private router = inject(RouterExtensions); + + account() { + this.router.navigate(['/account']); + } + + signOut() { + this.router.router.navigateByUrl('/login'); + } + + search() { + this.router.navigate(['/home', { outlets: { searchTab: ['search'] } }]); + } +} diff --git a/fixtures/ns-angular-demo-app/src/app/share/share-dialog.component.ts b/fixtures/ns-angular-demo-app/src/app/share/share-dialog.component.ts new file mode 100644 index 0000000..ed8c735 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/app/share/share-dialog.component.ts @@ -0,0 +1,10 @@ +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { NativeScriptCommonModule } from '@nativescript/angular'; + +@Component({ + selector: 'ns-share-dialog', + template: '', + imports: [NativeScriptCommonModule], + schemas: [NO_ERRORS_SCHEMA] +}) +export class ShareDialogComponent {} diff --git a/fixtures/ns-angular-demo-app/src/main.ts b/fixtures/ns-angular-demo-app/src/main.ts new file mode 100644 index 0000000..b535346 --- /dev/null +++ b/fixtures/ns-angular-demo-app/src/main.ts @@ -0,0 +1,10 @@ +import { bootstrapApplication, provideNativeScriptRouter, runNativeScriptAngularApp } from '@nativescript/angular'; +import { AppComponent } from './app/app.component'; +import { routes } from './app/app.routes'; + +runNativeScriptAngularApp({ + appModuleBootstrap: () => + bootstrapApplication(AppComponent, { + providers: [provideNativeScriptRouter(routes)] + }) +}); diff --git a/fixtures/ns-angular-demo-app/tsconfig.json b/fixtures/ns-angular-demo-app/tsconfig.json new file mode 100644 index 0000000..6e8f66e --- /dev/null +++ b/fixtures/ns-angular-demo-app/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "esnext", + "target": "es2020", + "moduleResolution": "bundler", + "experimentalDecorators": true, + "paths": { + "~/*": ["./src/*"] + } + }, + "files": ["./src/main.ts"] +} diff --git a/fixtures/ns-core-demo-app/App_Resources/iOS/Info.plist b/fixtures/ns-core-demo-app/App_Resources/iOS/Info.plist new file mode 100644 index 0000000..81cb536 --- /dev/null +++ b/fixtures/ns-core-demo-app/App_Resources/iOS/Info.plist @@ -0,0 +1,17 @@ + + + + + CFBundleDisplayName + NS Core Demo + CFBundleURLTypes + + + CFBundleURLSchemes + + nscore + + + + + diff --git a/fixtures/ns-core-demo-app/app/app.ts b/fixtures/ns-core-demo-app/app/app.ts new file mode 100644 index 0000000..865d16b --- /dev/null +++ b/fixtures/ns-core-demo-app/app/app.ts @@ -0,0 +1,3 @@ +import { Application } from '@nativescript/core'; + +Application.run({ moduleName: 'main-page' }); diff --git a/fixtures/ns-core-demo-app/app/main-page.ts b/fixtures/ns-core-demo-app/app/main-page.ts new file mode 100644 index 0000000..83c5356 --- /dev/null +++ b/fixtures/ns-core-demo-app/app/main-page.ts @@ -0,0 +1,17 @@ +import { EventData, Page } from '@nativescript/core'; +import { HelloWorldModel } from './main-view-model'; + +export function onNavigatingTo(args: EventData) { + const page = args.object; + page.bindingContext = new HelloWorldModel(); +} + +export function onOpenDetails(args: EventData) { + const page = (args.object).page as Page; + page.frame.navigate({ moduleName: 'pages/details/details-page', context: { id: 42 } }); +} + +export function onOpenFilters(args: EventData) { + const page = (args.object).page as Page; + page.showModal('pages/filters/filters-modal', { context: {}, closeCallback: () => undefined, fullscreen: false }); +} diff --git a/fixtures/ns-core-demo-app/app/main-page.xml b/fixtures/ns-core-demo-app/app/main-page.xml new file mode 100644 index 0000000..7cf7266 --- /dev/null +++ b/fixtures/ns-core-demo-app/app/main-page.xml @@ -0,0 +1,7 @@ + + + +