@shipvo/cli 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/agents.d.ts +14 -0
  2. package/dist/agents.js +124 -0
  3. package/dist/audit.d.ts +13 -0
  4. package/dist/audit.js +75 -0
  5. package/dist/ci.d.ts +3 -0
  6. package/dist/ci.js +42 -0
  7. package/dist/cli.js +28 -0
  8. package/dist/config.d.ts +2 -0
  9. package/dist/config.js +25 -10
  10. package/dist/doctor.d.ts +2 -0
  11. package/dist/doctor.js +25 -1
  12. package/dist/eas.d.ts +29 -0
  13. package/dist/eas.js +258 -0
  14. package/dist/ensure-playwright.d.ts +21 -0
  15. package/dist/ensure-playwright.js +64 -0
  16. package/dist/explore.d.ts +84 -0
  17. package/dist/explore.js +247 -0
  18. package/dist/help.d.ts +2 -2
  19. package/dist/help.js +24 -3
  20. package/dist/init.js +31 -14
  21. package/dist/policy.d.ts +2 -16
  22. package/dist/policy.js +2 -18
  23. package/dist/preview-recorder.d.ts +1 -0
  24. package/dist/preview-recorder.js +62 -0
  25. package/dist/preview.d.ts +3 -3
  26. package/dist/preview.js +110 -13
  27. package/dist/promote.d.ts +1 -1
  28. package/dist/promote.js +29 -2
  29. package/dist/publish.d.ts +1 -1
  30. package/dist/publish.js +12 -1
  31. package/dist/report-md.d.ts +26 -0
  32. package/dist/report-md.js +58 -0
  33. package/dist/run-bridge.d.ts +7 -0
  34. package/dist/run-bridge.js +374 -0
  35. package/dist/run-protocol.d.ts +32 -0
  36. package/dist/run-protocol.js +22 -0
  37. package/dist/run-ws.d.ts +8 -0
  38. package/dist/run-ws.js +89 -0
  39. package/dist/run.d.ts +86 -0
  40. package/dist/run.js +301 -0
  41. package/dist/sim.d.ts +4 -0
  42. package/dist/sim.js +19 -5
  43. package/dist/status.d.ts +48 -0
  44. package/dist/status.js +97 -4
  45. package/dist/store.d.ts +36 -0
  46. package/dist/store.js +266 -3
  47. package/dist/templates/capacitor/github-actions/build.yml +122 -0
  48. package/dist/test.d.ts +46 -0
  49. package/dist/test.js +190 -0
  50. package/package.json +6 -4
package/dist/help.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export declare const COMMANDS: readonly ["init", "sim", "preview", "publish", "promote", "auto-halt", "rollback", "app", "login", "doctor", "store", "status", "keys", "mcp", "keygen", "pubkey", "hash", "sign-file", "sign-json", "verify-json", "verify"];
2
- export declare const help = "shipvo <command>\n\nStart\n init <dir> Create a new app wired to Shipvo\n sim [--port 4545] Build the app and run it in the simulator\n sim --watch [--verbose] Rebuild on file changes and keep serving; --verbose shows full build errors\n preview [--json] Publish a public 24-hour preview link (here.now, no account)\n\nShip\n publish [--bands b0,b1,b2] Build, sign and publish an over-the-air release\n publish --dry-run [--out d] Check a release offline: build and sign, write nothing remote\n promote [--to b1,b2] Widen a staged release to more bands (same release, new signature)\n promote --dry-run Preview a promote, write nothing\n auto-halt [--threshold 0.2 --min-reports 20 | --off] Show, turn on or turn off auto-halt\n rollback --to <release> Republish an earlier release as a new release\n rollback --to <n> --dry-run Preview a rollback, write nothing\n app create | list | delete Register, list or remove apps on the hosted control plane\n login Sign in with GitHub and get a key for the hosted simulator\n store details --dry-run Draft Play Data safety answers and a privacy policy from shipvo.json\n status [--app id] [--json] Show how the newest releases are doing on phones\n doctor [--port <n>] Check setup before first publish\n keys Show the stored key's status and quota\n keys export [--out f] Print or save this app's signing seed, to back it up\n keys import [seed|--file f] Restore the signing seed on a new machine\n mcp Print the MCP config that agents need\n\nAdvanced\n keygen [--out seed.txt] Create an Ed25519 signing key and print the public key\n pubkey Print the public key for SHIPVO_SIGNING_SEED\n hash <file> Print the SHA-256 and size of a file\n sign-file <file> Print the Ed25519 signature (base64) of a file\n sign-json <file.json> Sign a JSON document and write the signature into it\n verify <pointer> [public-key-b64]\n Check a release pointer (such as one from publish --dry-run)\n verify-json <file.json> <public-key-b64>\n Check the signature of a JSON document\n";
1
+ export declare const COMMANDS: readonly ["init", "sim", "run", "explore", "preview", "publish", "promote", "auto-halt", "rollback", "app", "eas", "login", "doctor", "store", "ci", "status", "agents", "audit", "test", "keys", "mcp", "keygen", "pubkey", "hash", "sign-file", "sign-json", "verify-json", "verify"];
2
+ export declare const help = "shipvo <command>\n\nStart\n init <dir> Create a new app wired to Shipvo\n sim [--port 4545] Build the app and run it in the simulator\n sim --watch [--verbose] Rebuild on file changes and keep serving; --verbose shows full build errors\n First run installs playwright and, if no Chrome or Edge is found, downloads Chromium once.\n Set SHIPVO_NO_AUTO_INSTALL=1 to turn the automatic installs off.\n run android|ios [--no-open] [--port n] Open the app in a phone frame in your browser (no Playwright)\n run android|ios --native Run on an emulator or simulator with npx expo run:android / run:ios\n explore [--max-depth 3] [--max-steps 100] [--fail-on-findings] Walk the app in the simulator, write explore-report.md and .json\n preview [--json] Publish a public 24-hour preview link (here.now, no account)\n\nShip\n publish [--bands b0,b1,b2] [--release N] Build, sign and publish an over-the-air release\n publish --dry-run [--out d] Check a release offline: build and sign, write nothing remote\n promote [--to b1,b2] Widen a staged release to more bands (same release, new signature)\n promote --dry-run Preview a promote, write nothing\n promote --report <file> Refuse to widen when the test report has a failing flow for this release\n auto-halt [--threshold 0.2 --min-reports 20 | --off] Show, turn on or turn off auto-halt\n rollback --to <release> Republish an earlier release as a new release\n rollback --to <n> --dry-run Preview a rollback, write nothing\n app create | list | delete Register, list or remove apps on the hosted control plane\n eas setup [--dry-run] [--run] Make a Capacitor repo ready for eas build and submit (your own Expo account)\n eas build [args] Link the iOS scheme, then run eas build\n login Sign in with GitHub and get a key for the hosted simulator\n store details --dry-run Draft Play Data safety answers and a privacy policy from shipvo.json\n store setup [--company yes|no] [--android-now] [--play-account-before-2023-11-13]\n Pick the store accounts to open and write store-plan.json\n store screenshots [--out dir] [--sim url]\n Render each route at store sizes (fast mode: simulator, not the native UI)\n store testers [--package id] [--track alpha] [--play-key file] [--json]\n Steps to get your 12 testers; add the Shipvo Testers group to the closed track\n store status [--mark <step> [--as waiting|done]]\n Show each store's steps and what to do next\n ci init --framework capacitor [--dry-run] [--force]\n Write .github/workflows/shipvo-build.yml (signed Android and iOS builds)\n status [--app id] [--json] Show how releases do; --json lists what to do next (exit 0 ok, 1 action needed, 2 error)\n agents setup [--dry-run] [--json] Write AGENTS.md section and .mcp.json for AI agents\n audit <app> [--format json|csv] [--limit n] Export the audit log to stdout\n test [files] [--repair [--write]] [--report <file> [--release <id>]] Run tests/*.steps; --repair shows a diff for broken steps, --write saves it; --report writes JSON results keyed by commit and release\n doctor [--port <n>] Check setup before first publish\n keys Show the stored key's status and quota\n keys export [--out f] Print or save this app's signing seed, to back it up\n keys import [seed|--file f] Restore the signing seed on a new machine\n mcp Print the MCP config that agents need\n\nAdvanced\n keygen [--out seed.txt] Create an Ed25519 signing key and print the public key\n pubkey Print the public key for SHIPVO_SIGNING_SEED\n hash <file> Print the SHA-256 and size of a file\n sign-file <file> Print the Ed25519 signature (base64) of a file\n sign-json <file.json> Sign a JSON document and write the signature into it\n verify <pointer> [public-key-b64]\n Check a release pointer (such as one from publish --dry-run)\n verify-json <file.json> <public-key-b64>\n Check the signature of a JSON document\n";
3
3
  export declare function distance(a: string, b: string): number;
4
4
  /** Closest command within edit distance 2, or undefined. */
5
5
  export declare function suggest(input: string): string | undefined;
package/dist/help.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const COMMANDS = [
2
- "init", "sim", "preview", "publish", "promote", "auto-halt", "rollback", "app", "login", "doctor", "store", "status", "keys", "mcp",
2
+ "init", "sim", "run", "explore", "preview", "publish", "promote", "auto-halt", "rollback", "app", "eas", "login", "doctor", "store", "ci", "status", "agents", "audit", "test", "keys", "mcp",
3
3
  "keygen", "pubkey", "hash", "sign-file", "sign-json", "verify-json", "verify",
4
4
  ];
5
5
  export const help = `shipvo <command>
@@ -8,20 +8,41 @@ Start
8
8
  init <dir> Create a new app wired to Shipvo
9
9
  sim [--port 4545] Build the app and run it in the simulator
10
10
  sim --watch [--verbose] Rebuild on file changes and keep serving; --verbose shows full build errors
11
+ First run installs playwright and, if no Chrome or Edge is found, downloads Chromium once.
12
+ Set SHIPVO_NO_AUTO_INSTALL=1 to turn the automatic installs off.
13
+ run android|ios [--no-open] [--port n] Open the app in a phone frame in your browser (no Playwright)
14
+ run android|ios --native Run on an emulator or simulator with npx expo run:android / run:ios
15
+ explore [--max-depth 3] [--max-steps 100] [--fail-on-findings] Walk the app in the simulator, write explore-report.md and .json
11
16
  preview [--json] Publish a public 24-hour preview link (here.now, no account)
12
17
 
13
18
  Ship
14
- publish [--bands b0,b1,b2] Build, sign and publish an over-the-air release
19
+ publish [--bands b0,b1,b2] [--release N] Build, sign and publish an over-the-air release
15
20
  publish --dry-run [--out d] Check a release offline: build and sign, write nothing remote
16
21
  promote [--to b1,b2] Widen a staged release to more bands (same release, new signature)
17
22
  promote --dry-run Preview a promote, write nothing
23
+ promote --report <file> Refuse to widen when the test report has a failing flow for this release
18
24
  auto-halt [--threshold 0.2 --min-reports 20 | --off] Show, turn on or turn off auto-halt
19
25
  rollback --to <release> Republish an earlier release as a new release
20
26
  rollback --to <n> --dry-run Preview a rollback, write nothing
21
27
  app create | list | delete Register, list or remove apps on the hosted control plane
28
+ eas setup [--dry-run] [--run] Make a Capacitor repo ready for eas build and submit (your own Expo account)
29
+ eas build [args] Link the iOS scheme, then run eas build
22
30
  login Sign in with GitHub and get a key for the hosted simulator
23
31
  store details --dry-run Draft Play Data safety answers and a privacy policy from shipvo.json
24
- status [--app id] [--json] Show how the newest releases are doing on phones
32
+ store setup [--company yes|no] [--android-now] [--play-account-before-2023-11-13]
33
+ Pick the store accounts to open and write store-plan.json
34
+ store screenshots [--out dir] [--sim url]
35
+ Render each route at store sizes (fast mode: simulator, not the native UI)
36
+ store testers [--package id] [--track alpha] [--play-key file] [--json]
37
+ Steps to get your 12 testers; add the Shipvo Testers group to the closed track
38
+ store status [--mark <step> [--as waiting|done]]
39
+ Show each store's steps and what to do next
40
+ ci init --framework capacitor [--dry-run] [--force]
41
+ Write .github/workflows/shipvo-build.yml (signed Android and iOS builds)
42
+ status [--app id] [--json] Show how releases do; --json lists what to do next (exit 0 ok, 1 action needed, 2 error)
43
+ agents setup [--dry-run] [--json] Write AGENTS.md section and .mcp.json for AI agents
44
+ audit <app> [--format json|csv] [--limit n] Export the audit log to stdout
45
+ test [files] [--repair [--write]] [--report <file> [--release <id>]] Run tests/*.steps; --repair shows a diff for broken steps, --write saves it; --report writes JSON results keyed by commit and release
25
46
  doctor [--port <n>] Check setup before first publish
26
47
  keys Show the stored key's status and quota
27
48
  keys export [--out f] Print or save this app's signing seed, to back it up
package/dist/init.js CHANGED
@@ -97,7 +97,7 @@ export async function init(argv) {
97
97
  react: SDK.react, "react-dom": SDK.react, "react-native": SDK.rn, "react-native-web": SDK.rnw,
98
98
  "@shipvo/device": "^0.1.0", "@shipvo/runtime": "^0.1.0",
99
99
  },
100
- devDependencies: { "@shipvo/cli": "^0.1.0", "@shipvo/sim": "^0.1.0", "@shipvo/test": "^0.1.0", "@types/react": "~19.3.0", playwright: "^1.63.0", typescript: "~6.0.3", vitest: "^2.1.4" },
100
+ devDependencies: { "@shipvo/cli": "^0.1.0", "@shipvo/sim": "^0.1.0", "@shipvo/test": "^0.1.0", "@types/react": "~19.3.0", playwright: ">=1.63.0 <1.65.0", typescript: "~6.0.3", vitest: "^2.1.4" },
101
101
  }, null, 2) + "\n");
102
102
  w("app.json", JSON.stringify({ expo: {
103
103
  name, slug: appId, version: "1.0.0", orientation: "portrait", userInterfaceStyle: "automatic",
@@ -133,7 +133,7 @@ export const RELEASE = { headline: "Hello from ${name}.", note: "Edit src/releas
133
133
  w("App.tsx", SHOP_APP_TSX(name));
134
134
  w("src/cart.tsx", SHOP_CART_STATE);
135
135
  w("src/money.ts", SHOP_MONEY);
136
- w("src/data/shop.json", SHOP_CONFIG);
136
+ w("src/data/shop.json", SHOP_CONFIG(name));
137
137
  w("src/screens/List.tsx", SHOP_LIST);
138
138
  w("src/screens/Detail.tsx", SHOP_DETAIL);
139
139
  w("src/screens/Cart.tsx", SHOP_CART);
@@ -158,14 +158,14 @@ Publish: shipvo login, then shipvo app create (hosted), or set CLOUDFLARE_API_TO
158
158
  (self-hosting instead? see docs/self-hosting.md)
159
159
  AI agents: npx -y @shipvo/mcp points Claude / Cursor / ChatGPT at the simulator; then open_app("${appId}")
160
160
 
161
- Install first: npm install (or pnpm / bun), then npx playwright install chromium
162
-
163
161
  Next steps:
164
162
  1. cd ${dirArg}
165
- 2. npx shipvo doctor # setup checks
166
- 3. npm run sim # open the app in the simulator on http://localhost:4545
167
- 4. npx shipvo publish --dry-run # see what a release would contain
168
- 5. npx shipvo preview # get a link you can open on any phone
163
+ 2. npm install # or pnpm install / bun install; needed before any shipvo command
164
+ 3. npx shipvo sim # open the app on a simulated phone in your browser
165
+ 4. npx shipvo doctor # setup checks
166
+ 5. npx shipvo app create ${appId} # register the app (run npx shipvo login first)
167
+ 6. npx shipvo publish # send your first update
168
+ 7. npx shipvo preview # get a link you can open on any phone
169
169
  `);
170
170
  }
171
171
  const APP_TSX = (name, appId) => `import { useEffect, useRef, useState } from "react";
@@ -482,7 +482,7 @@ const SHOP_DATA = JSON.stringify([
482
482
  { id: "wool-beanie", name: "Wool beanie", price: 2400, description: "A warm hat in undyed wool." },
483
483
  { id: "steel-bottle", name: "Steel bottle", price: 2900, description: "A one litre bottle with a screw cap." },
484
484
  ], null, 2) + "\n";
485
- const SHOP_CONFIG = JSON.stringify({ currency: "$", code: "USD", position: "before", decimals: 2, locale: "en-US" }, null, 2) + "\n";
485
+ const SHOP_CONFIG = (name) => JSON.stringify({ name, currency: "$", code: "USD", position: "before", decimals: 2, locale: "en-US" }, null, 2) + "\n";
486
486
  const SHOP_MONEY = `// Prices are whole minor units (cents) in src/data/products.json. This is the one place that turns them into text.
487
487
  // To change the currency, edit src/data/shop.json. "code" is the ISO 4217 code sent to the payment provider ("$" is only a symbol). For FCFA: { "currency": "FCFA", "code": "XAF", "position": "after", "decimals": 0, "locale": "fr-FR" }
488
488
  import shop from "./data/shop.json";
@@ -496,6 +496,9 @@ export const formatPrice = (minor: number) => {
496
496
  return position === "after" ? num + " " + currency : currency + num;
497
497
  };
498
498
 
499
+ // The payee shown on the payment sheet and the receipt. It is "name" in shop.json.
500
+ export const shopName = (shop as { name?: string }).name ?? "Shop";
501
+
499
502
  // What device.pay.charge takes: "amount" is in whole currency units (10710 for 10710 FCFA, 12.5 for $12.50), as in the device docs.
500
503
  // Prices are minor units, so divide by 10 ** decimals. "currency" is the ISO code from shop.json.
501
504
  export const chargeRequest = (minor: number) => {
@@ -538,6 +541,15 @@ import products from "./src/data/products.json";
538
541
 
539
542
  type Route = { name: "list" } | { name: "detail"; id: string } | { name: "cart" };
540
543
 
544
+ // Send a placed order to the backend. The simulator records it, so end_session lists it. Add your own backend call here.
545
+ async function sendOrder(order: Record<string, unknown>) {
546
+ try {
547
+ const st = Runtime.getState();
548
+ if (!st?.baseUrl) return;
549
+ await fetch(st.baseUrl + "/o/" + st.appId, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ order, release: st.runningRelease ?? 0 }) });
550
+ } catch {}
551
+ }
552
+
541
553
  export default function App() {
542
554
  const [route, setRoute] = useState<Route>({ name: "list" });
543
555
  // Tell the runtime the first screen rendered, so this release is kept.
@@ -560,7 +572,7 @@ export default function App() {
560
572
  <CartProvider>
561
573
  <View style={s.root}>
562
574
  <StatusBar style="dark" />
563
- {route.name === "cart" ? <Cart onBack={toList} />
575
+ {route.name === "cart" ? <Cart onBack={toList} onOrder={sendOrder} />
564
576
  : product ? <Detail product={product} onBack={toList} onCart={toCart} />
565
577
  : <List title="${name}" products={products} onOpen={(id) => setRoute({ name: "detail", id })} onCart={toCart} />}
566
578
  </View>
@@ -637,10 +649,10 @@ const SHOP_CART = `import { useState } from "react";
637
649
  import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
638
650
  import { device } from "@shipvo/device";
639
651
  import { useCart } from "../cart";
640
- import { chargeRequest, formatPrice } from "../money";
652
+ import { chargeRequest, formatPrice, shopName } from "../money";
641
653
  import products from "../data/products.json";
642
654
 
643
- export function Cart({ onBack }: { onBack: () => void }) {
655
+ export function Cart({ onBack, onOrder }: { onBack: () => void; onOrder?: (order: Record<string, unknown>) => void | Promise<void> }) {
644
656
  const { lines, remove, clear, totalCents } = useCart();
645
657
  const [status, setStatus] = useState<string | null>(null);
646
658
  const [busy, setBusy] = useState(false);
@@ -650,8 +662,13 @@ export function Cart({ onBack }: { onBack: () => void }) {
650
662
  setBusy(true);
651
663
  setStatus("Paying...");
652
664
  try {
653
- const r = await device.pay.charge({ provider: "mobile_money", ...chargeRequest(totalCents), to: "My Shop", reference: "order-" + Date.now() }, "1234");
654
- if (r.ok) { clear(); setStatus("Order placed"); } else setStatus("Payment failed: " + r.reason);
665
+ const reference = "order-" + Date.now();
666
+ const r = await device.pay.charge({ provider: "mobile_money", ...chargeRequest(totalCents), to: shopName, reference }, "1234");
667
+ if (r.ok) {
668
+ // Only a paid order is sent to the backend.
669
+ await onOrder?.({ id: reference, total: totalCents, payment: "mobile_money", receipt: r.receipt, lines: lines.map((l) => ({ product_id: l.id, quantity: l.qty })) });
670
+ clear(); setStatus("Order placed");
671
+ } else setStatus("Payment failed: " + r.reason);
655
672
  } catch (e: any) { setStatus("Payment failed: " + String(e?.message ?? e)); }
656
673
  finally { setBusy(false); }
657
674
  };
package/dist/policy.d.ts CHANGED
@@ -1,18 +1,4 @@
1
- import { Band, type ShipvoPolicy } from "@shipvo/schema";
2
- type BandName = ReturnType<typeof Band.parse>;
3
- export type PolicyResult = {
4
- ok: true;
5
- } | {
6
- ok: false;
7
- rule: string;
8
- reason: string;
9
- };
10
- /** Checks one publish against the policy. Pure: `now` is read in local time. No policy means no rules. */
11
- export declare function checkPolicy(policy: ShipvoPolicy | undefined, ctx: {
12
- band: BandName;
13
- actorKind: "human" | "agent";
14
- now: Date;
15
- }): PolicyResult;
1
+ import { checkPolicy, type PolicyResult } from "@shipvo/schema";
2
+ export { checkPolicy, type PolicyResult };
16
3
  /** Reads the `policy` value from a parsed shipvo.json (throws naming the bad field) and checks it. The widest band in `bands` counts. */
17
4
  export declare function checkPublishPolicy(rawPolicy: unknown, bands: string[], now?: Date, env?: NodeJS.ProcessEnv): PolicyResult;
18
- export {};
package/dist/policy.js CHANGED
@@ -1,22 +1,6 @@
1
- import { Band, parsePolicy } from "@shipvo/schema";
1
+ import { Band, checkPolicy, parsePolicy } from "@shipvo/schema";
2
2
  const ORDER = ["b0", "b1", "b2"];
3
- const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; // Date#getDay order
4
- /** Checks one publish against the policy. Pure: `now` is read in local time. No policy means no rules. */
5
- export function checkPolicy(policy, ctx) {
6
- if (!policy)
7
- return { ok: true };
8
- const { band, actorKind, now } = ctx;
9
- if (policy.maxBand && ORDER.indexOf(band) > ORDER.indexOf(policy.maxBand))
10
- return { ok: false, rule: "maxBand", reason: `this publish reaches ${band}, but policy.maxBand is ${policy.maxBand}` };
11
- if (actorKind === "agent" && policy.agentMaxBand && ORDER.indexOf(band) > ORDER.indexOf(policy.agentMaxBand))
12
- return { ok: false, rule: "agentMaxBand", reason: `an agent-run publish reaches ${band}, but policy.agentMaxBand is ${policy.agentMaxBand}` };
13
- if (policy.blockedWeekdays) {
14
- const day = DAYS[now.getDay()], from = policy.blockedAfterHour ?? 0;
15
- if (policy.blockedWeekdays.includes(day) && now.getHours() >= from)
16
- return { ok: false, rule: "blockedWeekdays", reason: `publishing is blocked on ${day} from ${String(from).padStart(2, "0")}:00 local time (policy.blockedWeekdays${policy.blockedAfterHour !== undefined ? ", policy.blockedAfterHour" : ""})` };
17
- }
18
- return { ok: true };
19
- }
3
+ export { checkPolicy };
20
4
  /** Reads the `policy` value from a parsed shipvo.json (throws naming the bad field) and checks it. The widest band in `bands` counts. */
21
5
  export function checkPublishPolicy(rawPolicy, bands, now = new Date(), env = process.env) {
22
6
  if (rawPolicy === undefined)
@@ -0,0 +1 @@
1
+ export declare const RECORDER_JS: string;
@@ -0,0 +1,62 @@
1
+ // The recorder that runs inside the preview frame page. It is plain browser JavaScript kept as a string,
2
+ // so `shipvo preview` can inline it. `shipvoExport` mirrors `exportTest` in packages/mcp/src/export.ts;
3
+ // a test keeps the two outputs identical.
4
+ export const RECORDER_JS = String.raw `
5
+ var shipvoSteps=[];
6
+ function shipvoQ(s){return JSON.stringify(s).replace(new RegExp("\\u2028","g"),"\\\\u2028").replace(new RegExp("\\u2029","g"),"\\\\u2029")}
7
+ function shipvoOneLine(s){return s.replace(new RegExp("[\\r\\n\\u2028\\u2029]+","g")," ")}
8
+ function shipvoEnv(l){return "SHIPVO_TEST_"+(l.replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").toUpperCase()||"FIELD")}
9
+ function shipvoLine(s){
10
+ var t=s.target;
11
+ if(s.action==="note")return ["// "+shipvoOneLine(s.text||"")];
12
+ if(s.action==="set_device")return ["await phone.device.set("+shipvoQ(s.device||{})+");"];
13
+ if(s.action==="back")return ["await phone.back();"];
14
+ if(s.action==="scroll")return ["await phone.scroll("+shipvoQ(["down","up","left","right"].indexOf(s.direction)>=0?s.direction:"down")+");"];
15
+ if(s.action==="tap"){
16
+ if(!t)return ["// TODO: tap without a target"];
17
+ if(t.matches>1)return ["// TODO: ambiguous target "+shipvoOneLine(shipvoQ(t.text))];
18
+ return ["await phone.tap("+shipvoQ(t.text)+");"];
19
+ }
20
+ if(s.action==="type"){
21
+ var label=s.fieldLabel||(t&&t.text);
22
+ if(!label)return ["// TODO: type without a field label"];
23
+ if(t&&t.matches>1)return ["// TODO: ambiguous target "+shipvoOneLine(shipvoQ(label))];
24
+ return ["await phone.type("+shipvoQ(label)+", "+(s.passwordLike?"process.env."+shipvoEnv(label)+"!":shipvoQ(s.text||""))+");"];
25
+ }
26
+ return [];
27
+ }
28
+ function shipvoExport(app,steps,title){
29
+ var out=['import { test } from "@shipvo/test/vitest";','import { openApp } from "@shipvo/test";','','test('+shipvoQ("recorded session for "+(title||app))+', async () => {',' const phone = await openApp({ app: '+shipvoQ(app)+' });'];
30
+ steps.forEach(function(s){
31
+ var lines=shipvoLine(s);
32
+ lines.forEach(function(l){out.push(" "+l)});
33
+ if(s.changed&&lines.length&&lines[0].indexOf("//")!==0&&s.action!=="open")out.push(" await phone.waitFor(/\\S/); // TODO: name the text that should appear");
34
+ });
35
+ out.push("});","");
36
+ return out.join("\n");
37
+ }
38
+ function shipvoLabel(el){
39
+ var l=el.getAttribute("aria-label")||(el.labels&&el.labels[0]&&el.labels[0].textContent)||el.getAttribute("placeholder")||el.textContent||"";
40
+ return l.replace(/\s+/g," ").trim();
41
+ }
42
+ function shipvoCount(doc,label){
43
+ var n=0;doc.querySelectorAll("button,a,[role=button],[role=link],[role=tab],input,textarea,[aria-label]").forEach(function(e){if(shipvoLabel(e)===label)n++});
44
+ return n;
45
+ }
46
+ function shipvoAttach(frame,onStep){
47
+ var doc=frame.contentDocument;if(!doc||doc.__shipvoAttached)return;
48
+ doc.__shipvoAttached=true;
49
+ var sel="button,a,[role=button],[role=link],[role=tab],[role=switch],[role=checkbox]";
50
+ doc.addEventListener("click",function(e){
51
+ var el=e.target&&e.target.closest&&e.target.closest(sel);
52
+ if(!el||el.matches("input,textarea"))return;
53
+ var text=shipvoLabel(el);if(!text){onStep({action:"note",text:"TODO: tap without a label"});return}
54
+ onStep({action:"tap",target:{text:text,role:el.getAttribute("role")||el.tagName.toLowerCase(),matches:shipvoCount(doc,text)},changed:true});
55
+ },true);
56
+ doc.addEventListener("change",function(e){
57
+ var el=e.target;if(!el||!/^(INPUT|TEXTAREA)$/.test(el.tagName))return;
58
+ var label=shipvoLabel(el);if(!label)return;
59
+ onStep({action:"type",fieldLabel:label,text:el.value,passwordLike:el.type==="password",target:{text:label,role:"textbox",matches:shipvoCount(doc,label)}});
60
+ },true);
61
+ }
62
+ `;
package/dist/preview.d.ts CHANGED
@@ -20,10 +20,10 @@ export declare const blockedPath: (rel: string) => boolean;
20
20
  export declare function refuseSecrets(files: PreviewFile[], secrets?: string[]): void;
21
21
  /** Reads a build folder into upload entries, skipping files that must never be uploaded. */
22
22
  export declare function collectFiles(dir: string): PreviewFile[];
23
- /** The page people open: the app in an Android or iPhone frame, with skin, size and theme switches and a back control. */
24
- export declare function framePage(name: string): string;
23
+ /** The page people open: the app in an Android or iPhone frame, with skin, size and theme switches, a back control and a recorder panel. */
24
+ export declare function framePage(name: string, appId?: string): string;
25
25
  /** Puts the frame page at index.html and the app's own entry at app.html (its asset paths are absolute, so it stays at the site root). */
26
- export declare function wrapBuild(buildDir: string, name: string): PreviewFile[];
26
+ export declare function wrapBuild(buildDir: string, name: string, appId?: string): PreviewFile[];
27
27
  /** Publishes to here.now anonymously: create, upload, finalize. Refreshes expired upload URLs and waits out 429s. */
28
28
  export declare function publishToHereNow(files: PreviewFile[], o?: PreviewOpts): Promise<PreviewResult>;
29
29
  export declare const NOTICE = "This link is public: anyone who has it can open the preview. It expires in 24 hours unless you open the claim link and sign in to here.now, which keeps it.";
package/dist/preview.js CHANGED
@@ -4,6 +4,7 @@ import { spawn } from "node:child_process";
4
4
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
5
5
  import { join, relative, sep, extname } from "node:path";
6
6
  import { args as parse, loadConfig } from "./config.js";
7
+ import { RECORDER_JS } from "./preview-recorder.js";
7
8
  const API = "https://here.now/api/v1";
8
9
  const CLIENT = "shipvo/cli";
9
10
  const TYPES = {
@@ -72,8 +73,8 @@ export function collectFiles(dir) {
72
73
  walk(dir);
73
74
  return out;
74
75
  }
75
- /** The page people open: the app in an Android or iPhone frame, with skin, size and theme switches and a back control. */
76
- export function framePage(name) {
76
+ /** The page people open: the app in an Android or iPhone frame, with skin, size and theme switches, a back control and a recorder panel. */
77
+ export function framePage(name, appId = name) {
77
78
  const title = name.replace(/[<>&"']/g, "");
78
79
  return `<!doctype html>
79
80
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
@@ -85,7 +86,7 @@ body.dark{background:#15171a;color:#eee}
85
86
  .bar{display:flex;gap:8px;justify-content:center;align-items:center;padding:10px;flex-wrap:wrap}
86
87
  .bar button,.back{font:inherit;padding:6px 12px;border:1px solid #888;border-radius:8px;background:transparent;color:inherit;cursor:pointer}
87
88
  .bar button[aria-pressed=true]{background:#2563eb;border-color:#2563eb;color:#fff}
88
- .stage{display:flex;justify-content:center;padding:0 10px 20px}
89
+ .stage{display:flex;justify-content:center;align-items:flex-start;gap:16px;padding:0 10px 20px;flex-wrap:wrap}
89
90
  .device{display:flex;flex-direction:column;background:#000;color:#fff;border-radius:28px;box-shadow:0 8px 30px #0005;overflow:hidden;width:360px;max-width:100%;box-sizing:border-box;padding:0 6px}
90
91
  .device[data-skin=iphone]{border-radius:44px;padding:0 10px}
91
92
  .status{display:flex;justify-content:space-between;align-items:center;height:28px;padding:0 14px;font-size:12px;flex:none}
@@ -103,42 +104,138 @@ iframe{border:0;background:#fff;width:100%;flex:1;min-height:0;display:block}
103
104
  .device[data-skin=iphone] .back::after{content:"";position:absolute;left:0;right:0;top:50%;height:5px;margin-top:-2px;border-radius:3px;background:#fff}
104
105
  .back:focus-visible{outline:2px solid #fff;outline-offset:2px}
105
106
  .device[data-skin=android] .back{border:0}
107
+ .side{max-width:360px;margin:0 auto 20px;padding:0 10px;box-sizing:border-box}
108
+ .side h2{font-size:14px;margin:0 0 6px}.side ol{padding-left:20px;margin:6px 0}.side li{margin-bottom:6px}.side small{opacity:.7}
109
+ .side button,.side input{font:inherit}
110
+ .side input{width:100%;box-sizing:border-box;min-height:36px;background:transparent;color:inherit;border:1px solid #888;border-radius:6px;padding:4px 8px;margin:4px 0}
111
+ body.dark .side input{background:#15171a;color:#eee}body.dark .side small{opacity:.85}
112
+ .fab{position:fixed;right:12px;bottom:12px;z-index:10;min-width:44px;min-height:44px;padding:8px 14px;font:inherit;border:1px solid #888;border-radius:22px;background:#fff;color:#111;cursor:pointer;box-shadow:0 2px 8px #0004}
113
+ .fab[aria-pressed=true]{background:#2563eb;border-color:#2563eb;color:#fff}
114
+ .fab:focus-visible{outline:3px solid #f59e0b;outline-offset:2px}
115
+ body.dark .fab{background:#2a2d31;color:#eee}body.dark .fab[aria-pressed=true]{background:#2563eb;color:#fff}
116
+ .device{position:relative}
117
+ #pin{position:absolute;inset:0;display:none;cursor:crosshair;background:#2563eb22;z-index:5}
118
+ #pin.on{display:block}
119
+ #pin .hint{position:absolute;left:8px;right:8px;top:8px;padding:6px 8px;border-radius:6px;background:#111;color:#fff;font-size:13px;text-align:center}
106
120
  .note{text-align:center;opacity:.7;padding:0 12px 16px}
107
- @media (max-width:500px){.bar,.note{display:none}.stage{padding:0}.device{padding:0!important;width:100vw!important;height:100vh!important;height:100dvh!important;border-radius:0!important;box-shadow:none}}
121
+ .recp a{color:inherit;text-decoration:underline}body.dark .recp a{color:#93c5fd}.recp button:focus-visible,.bar button:focus-visible,.recp a:focus-visible{outline:2px solid #60a5fa;outline-offset:2px}
122
+ .recp{max-width:520px;margin:0 auto;padding:0 12px 24px;text-align:center}.recp p{opacity:.8}.recp button{font:inherit;padding:6px 12px;border:1px solid #888;border-radius:8px;background:transparent;color:inherit;cursor:pointer}
123
+ .panel{width:280px;max-width:100%;box-sizing:border-box;display:grid;gap:10px;padding:12px;border:1px solid #8886;border-radius:12px}
124
+ .panel h2{font-size:15px;margin:0}.panel fieldset{border:0;margin:0;padding:0;display:grid;gap:6px}.panel legend{font-weight:600;padding:0 0 4px}
125
+ .panel .row{display:flex;gap:6px;flex-wrap:wrap}.panel input,.panel select,.panel button{font:inherit;padding:5px 8px;border:1px solid #888;border-radius:8px;background:transparent;color:inherit;min-width:0}
126
+ body.dark .panel select,body.dark .panel option{background:#15171a;color:#eee}.panel button[aria-pressed=true]{background:#2563eb;border-color:#2563eb;color:#fff}.panel input{flex:1 1 80px}.panel button{cursor:pointer}.panel .sim{opacity:.7;font-size:12px;margin:0}
127
+ @media (max-width:500px){.bar,.note{display:none}.stage{padding:0;display:block}.panel{width:auto;margin:10px;max-width:none}.device{padding:0!important;width:100vw!important;height:100vh!important;height:100dvh!important;border-radius:0!important;box-shadow:none}}
108
128
  </style></head><body>
109
129
  <div class="bar" role="toolbar" aria-label="Preview options">
110
130
  <button data-skin="android" aria-pressed="true">Android</button><button data-skin="iphone" aria-pressed="false">iPhone</button>
111
131
  <button data-size="phone" aria-pressed="true">Phone</button><button data-size="large" aria-pressed="false">Large phone</button><button data-size="tablet" aria-pressed="false">Tablet</button>
112
132
  <button id="theme" aria-pressed="false">Dark</button></div>
133
+ <button id="comment" class="fab" aria-pressed="false">Comment</button>
113
134
  <div class="stage"><div class="device" id="device" data-skin="android" style="width:360px;height:760px">
114
135
  <div class="status" id="status"><span id="clock">9:41</span><span class="notch"></span><span class="icons" id="icons">&#9679;&#9679; 5G 100%</span></div>
115
- <iframe id="app" src="app.html" title="${title}"></iframe>
136
+ <iframe id="app" src="app.html" title="${title}"></iframe><div id="pin" role="button" tabindex="0" aria-label="Tap where the problem is, or press Enter to mark the centre of the device"><span class="hint">Tap where the problem is. Press Escape to cancel.</span></div>
116
137
  <div class="nav"><button class="back" id="back" aria-label="Back" title="Back">&#9664; Back</button></div>
117
- </div></div>
138
+ </div>
139
+ <aside class="panel" id="device-panel" aria-label="Device controls"><h2>Device</h2>
140
+ <fieldset><legend>Location</legend><div class="row"><select data-control="location-list" aria-label="Place"><option value="4.0511,9.7679">Douala</option><option value="48.8566,2.3522">Paris</option><option value="40.7128,-74.006">New York</option><option value="-1.2921,36.8219">Nairobi</option></select><button data-control="location-set">Set</button></div>
141
+ <div class="row"><input data-control="lat" placeholder="Latitude" inputmode="decimal" aria-label="Latitude"><input data-control="lng" placeholder="Longitude" inputmode="decimal" aria-label="Longitude"><button data-control="coords-set">Go</button></div></fieldset>
142
+ <fieldset><legend>Camera</legend><div class="row"><select data-control="camera-list" aria-label="Barcode"><option value="ean13|6181234567890">EAN-13 6181234567890</option><option value="qr|https://example.com">QR https://example.com</option><option value="none|">Nothing in view</option></select><button data-control="camera-set">Show</button></div></fieldset>
143
+ <fieldset><legend>Fingerprint / Face ID</legend><div class="row"><button data-control="bio-success">Match</button><button data-control="bio-fail">No match</button></div></fieldset>
144
+ <fieldset><legend>Payment</legend><div class="row"><button data-control="pay-approve">Approve</button><button data-control="pay-decline">Decline</button></div></fieldset>
145
+ <fieldset><legend>Network</legend><div class="row"><button data-control="net-online">Online</button><button data-control="net-slow">Slow</button><button data-control="net-offline">Offline</button></div></fieldset>
146
+ <fieldset><legend>Push notification</legend><input data-control="push-title" placeholder="Title" aria-label="Title"><input data-control="push-body" placeholder="Body" aria-label="Body"><button data-control="push-send">Send</button></fieldset>
147
+ <p class="sim" id="panel-status" role="status">All device events here are simulated.</p></aside>
148
+ </div>
149
+ <div class="side" id="side"><h2>Comments (<span id="count">0</span>)</h2>
150
+ <form id="noteform" hidden><label for="notetext">Note for the point you tapped</label><br><input id="notetext"> <button type="submit">Save</button></form><span id="notesaved" role="status"></span>
151
+ <ol id="notelist"></ol>
152
+ <button id="export">Export comments.json</button>
153
+ <p><small>Notes live in this browser until you export them. Nothing is uploaded.</small></p></div>
118
154
  <p class="note">${title}: simulated device, public preview. System sheets such as permissions and payments are simulated.</p>
119
- <script>
155
+ <script data-notes>
156
+ (function(){
157
+ var KEY="shipvo-preview-comments",steps=[],pending=null,notes=[];
158
+ function $(id){return document.getElementById(id)}
159
+ try{notes=JSON.parse(localStorage.getItem(KEY)||"[]");if(!Array.isArray(notes))notes=[]}catch(e){notes=[]}
160
+ function save(){try{localStorage.setItem(KEY,JSON.stringify(notes))}catch(e){}}
161
+ function esc(t){return String(t).replace(/[&<>"']/g,function(c){return"&#"+c.charCodeAt(0)+";"})}
162
+ function screenName(){try{var w=$("app").contentWindow,t=w.document.title,r=w.location.pathname+(w.location.hash||"");return t?t+" ("+r+")":r}catch(e){return"unknown"}}
163
+ function step(text){steps.push(text)}
164
+ function add(note,x,y){var n={note:String(note),x:Math.round(x),y:Math.round(y),screen:screenName(),steps:steps.slice()};notes.push(n);save();render();return n}
165
+ function exportJson(){return JSON.stringify(notes,null,2)}
166
+ function render(){var l=$("notelist"),c=$("count");if(c)c.textContent=notes.length;if(l)l.innerHTML=notes.map(function(n){return"<li>"+esc(n.note)+"<br><small>"+esc(n.screen)+" at "+Number(n.x)+","+Number(n.y)+", "+(n.steps||[]).length+" steps</small></li>"}).join("")}
167
+ function download(){var b=new Blob([exportJson()],{type:"application/json"}),u=URL.createObjectURL(b),a=document.createElement("a");a.href=u;a.download="comments.json";document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(u)}
168
+ function label(e){var t=e.getAttribute&&(e.getAttribute("aria-label")||e.textContent)||"";return String(t).trim().slice(0,40)}
169
+ var seen=typeof WeakSet==="function"?new WeakSet():null;
170
+ function watch(){try{var d=$("app").contentWindow.document;if(seen){if(seen.has(d))return;seen.add(d)}d.addEventListener("click",function(ev){var e=ev.target,k=e&&e.closest&&e.closest("button,[role=button],a,input");step("tap "+(k?label(k)||"control":"screen"))},true)}catch(e){}}
171
+ function setMode(on,refocus){var p=$("pin"),b=$("comment");if(p)p.className=on?"on":"";if(b){b.setAttribute("aria-pressed",on);if(refocus&&b.focus)b.focus()}if(on&&p&&p.focus)p.focus()}
172
+ var cb=$("comment");if(cb)cb.onclick=function(){setMode($("pin").className!=="on")};
173
+ function place(x,y){pending={x:x,y:y};setMode(false);$("noteform").hidden=false;if($("notetext").focus)$("notetext").focus()}
174
+ var pin=$("pin");if(pin){pin.onclick=function(ev){var r=pin.getBoundingClientRect?pin.getBoundingClientRect():{left:0,top:0};place(ev.clientX-r.left,ev.clientY-r.top)};
175
+ pin.onkeydown=function(ev){if(ev.key==="Enter"||ev.key===" "){if(ev.preventDefault)ev.preventDefault();var r=pin.getBoundingClientRect?pin.getBoundingClientRect():{width:0,height:0};place((r.width||0)/2,(r.height||0)/2)}}}
176
+ var fm=$("noteform");if(fm)fm.onsubmit=function(ev){if(ev&&ev.preventDefault)ev.preventDefault();var t=$("notetext");if(pending&&t.value.trim()){add(t.value.trim(),pending.x,pending.y)}t.value="";pending=null;fm.hidden=true;var st=$("notesaved");if(st)st.textContent="Saved";setMode(false,true);return false};
177
+ if(document.addEventListener)document.addEventListener("keydown",function(ev){if(ev.key==="Escape"){var was=$("pin")&&$("pin").className==="on",f=$("noteform");if(was||(f&&!f.hidden)){if(f)f.hidden=true;pending=null;setMode(false,true)}}});
178
+ var ex=$("export");if(ex)ex.onclick=download;
179
+ var bk=$("back");if(bk&&bk.addEventListener)bk.addEventListener("click",function(){step("back")});
180
+ var ap=$("app");if(ap){if(ap.addEventListener)ap.addEventListener("load",watch);watch()}
181
+ window.shipvoNotes={add:add,step:step,list:function(){return notes.slice()},exportJson:exportJson,download:download};
182
+ render();
183
+ })();
184
+ </script>
185
+ <section class="recp" aria-label="Recorder"><p id="honesty">This is the app's web build on Shipvo's simulated device, measured against Android and iOS in <a href="https://github.com/shipvo/shipvo/blob/main/docs/parity-results.md">docs/parity-results.md</a>. Native-only UI can differ.</p>
186
+ <button id="rec" aria-pressed="false">Record</button> <button id="dl" disabled>Download test</button> <span id="stepcount" aria-live="polite">0 steps</span></section>
187
+ <script>${RECORDER_JS}
188
+ var APP=${JSON.stringify(appId).replace(/</g, "\\u003c")},TITLE=${JSON.stringify(title).replace(/</g, "\\u003c")},recording=false;
189
+ function step(s){if(recording){shipvoSteps.push(s);document.getElementById("stepcount").textContent=shipvoSteps.length+(shipvoSteps.length===1?" step":" steps");document.getElementById("dl").disabled=false}}
190
+ document.getElementById("app").addEventListener("load",function(){shipvoAttach(this,step)});
191
+ (function(a){if(a.contentDocument&&a.contentDocument.readyState==="complete")shipvoAttach(a,step)})(document.getElementById("app"));
192
+ document.getElementById("rec").onclick=function(){recording=!recording;if(recording){shipvoSteps.length=0;document.getElementById("stepcount").textContent="0 steps";document.getElementById("dl").disabled=true}this.setAttribute("aria-pressed",recording)};
193
+ document.getElementById("dl").onclick=function(){var a=document.createElement("a");a.href=URL.createObjectURL(new Blob([shipvoExport(APP,shipvoSteps,TITLE)],{type:"text/typescript"}));a.download="recorded-session.test.ts";a.click()};
120
194
  var SIZES={phone:[360,760],large:[412,892],tablet:[820,1180]},IPHONE={phone:[390,844],large:[430,932],tablet:[820,1180]};
121
195
  var skin="android",size="phone",d=document.getElementById("device"),f=document.getElementById("app"),bk=document.getElementById("back");
122
196
  function pressed(sel,val,key){document.querySelectorAll(sel).forEach(function(o){o.setAttribute("aria-pressed",o.dataset[key]===val)})}
123
197
  function apply(){var s=(skin==="iphone"?IPHONE:SIZES)[size];d.dataset.skin=skin;d.style.width=s[0]+"px";d.style.height=s[1]+"px";bk.innerHTML=skin==="iphone"?"Back":"&#9664; Back";bk.setAttribute("aria-label","Back");document.getElementById("icons").innerHTML=skin==="iphone"?"5G &#9646; 100%":"&#9679;&#9679; 5G 100%";pressed("[data-skin]",skin,"skin");pressed("[data-size]",size,"size")}
124
- document.querySelectorAll("[data-skin]").forEach(function(b){b.onclick=function(){skin=b.dataset.skin;apply()}});
125
- document.querySelectorAll("[data-size]").forEach(function(b){b.onclick=function(){size=b.dataset.size;apply()}});
198
+ document.querySelectorAll("[data-skin]").forEach(function(b){b.onclick=function(){step({action:"note",text:"panel: skin set to "+b.dataset.skin});skin=b.dataset.skin;apply()}});
199
+ document.querySelectorAll("[data-size]").forEach(function(b){b.onclick=function(){step({action:"note",text:"panel: size set to "+b.dataset.size});size=b.dataset.size;apply()}});
126
200
  // Back does what the simulator's back action does: press the screen's Back button, else go back in history.
127
201
  function goBack(){var w=f.contentWindow;try{var doc=w&&w.document;if(!doc)return;var els=doc.querySelectorAll("button,[role=button],a");for(var i=0;i<els.length;i++){var e=els[i],t=(e.getAttribute("aria-label")||e.textContent||"").trim();if(t==="Back"){e.click();return}}w.history.back()}catch(x){}}
128
202
  bk.onclick=goBack;
129
- var t=document.getElementById("theme");t.onclick=function(){var k=!document.body.classList.contains("dark");document.body.classList.toggle("dark",k);t.setAttribute("aria-pressed",k);t.textContent=k?"Light":"Dark";f.style.colorScheme=k?"dark":"light"};
203
+ var t=document.getElementById("theme");t.onclick=function(){step({action:"note",text:"panel: theme toggled"});var k=!document.body.classList.contains("dark");document.body.classList.toggle("dark",k);t.setAttribute("aria-pressed",k);t.textContent=k?"Light":"Dark";f.style.colorScheme=k?"dark":"light"};
204
+ var P=document.getElementById("panel-status");
205
+ function pnSay(m){P.textContent=m+" (simulated)"}
206
+ function pnDev(){var d=f.contentWindow&&f.contentWindow.__pnDevice;if(!d){pnSay("The app is still loading");return null}return d}
207
+ function pnNum(c){var v=pnVal(c).trim();return v===""?NaN:Number(v)}
208
+ function pnVal(c){return document.querySelector('[data-control="'+c+'"]').value}
209
+ function pnSet(patch,msg){var d=pnDev();if(d){d.set(patch);step({action:"set_device",device:patch});pnSay(msg)}}
210
+ var pnDelay=0;
211
+ function pnSlow(){var w=f.contentWindow;if(!w||w.__pnSlow||!w.fetch)return;w.__pnSlow=1;var o=w.fetch.bind(w);w.fetch=function(){var a=arguments;return new Promise(function(r){setTimeout(r,pnDelay)}).then(function(){return o.apply(null,a)})}}
212
+ var A={
213
+ "location-set":function(){var v=pnVal("location-list").split(",");pnSet({location:{latitude:+v[0],longitude:+v[1],accuracy:20}},"Location set")},
214
+ "coords-set":function(){var a=pnNum("lat"),o=pnNum("lng");if(!(a>=-90&&a<=90&&o>=-180&&o<=180)){pnSay("Enter latitude -90 to 90 and longitude -180 to 180");return}pnSet({location:{latitude:a,longitude:o,accuracy:20}},"Location set")},
215
+ "camera-set":function(){var v=pnVal("camera-list").split("|");pnSet({camera:{barcode:v[0]==="none"?null:{type:v[0],data:v.slice(1).join("|")}}},"Camera shows "+(v[0]==="none"?"nothing":v[0]))},
216
+ "bio-success":function(){pnSet({biometrics:{nextResult:"success"}},"Fingerprint and Face ID match from now on")},
217
+ "bio-fail":function(){pnSet({biometrics:{nextResult:"fail"}},"Fingerprint and Face ID do not match from now on")},
218
+ "pay-approve":function(){pnSet({payment:{nextResult:"approve"}},"Payments are approved from now on")},
219
+ "pay-decline":function(){pnSet({payment:{nextResult:"decline"}},"Payments are declined from now on")},
220
+ "net-online":function(){pnDelay=0;pnSet({online:true},"Network online")},
221
+ "net-slow":function(){if(!pnDev())return;pnDelay=1500;pnSlow();pnSet({online:true},"Network slow (requests wait 1.5 s)")},
222
+ "net-offline":function(){pnDelay=0;pnSet({online:false},"Network offline")},
223
+ "push-send":function(){var d=pnDev();if(!d)return;var t=pnVal("push-title")||"Notification",b=pnVal("push-body");var ok=d.push({title:t,body:b})!==null;if(ok)step({action:"note",text:"panel: push sent"});pnSay(ok?"Push sent":"Not delivered: the app has not been granted notification permission")}
224
+ };
225
+ var pnGroups=[["bio-success","bio-fail"],["pay-approve","pay-decline"],["net-online","net-slow","net-offline"]];
226
+ document.querySelectorAll("[data-control]").forEach(function(e){var c=e.dataset.control;if(A[c])e.onclick=function(){A[c]();pnGroups.forEach(function(g){if(g.indexOf(c)<0)return;g.forEach(function(k){var b=document.querySelector('[data-control="'+k+'"]');if(b.setAttribute)b.setAttribute("aria-pressed",k===c)})})}});
130
227
  apply();
131
228
  </script></body></html>
132
229
  `;
133
230
  }
134
231
  /** Puts the frame page at index.html and the app's own entry at app.html (its asset paths are absolute, so it stays at the site root). */
135
- export function wrapBuild(buildDir, name) {
232
+ export function wrapBuild(buildDir, name, appId = name) {
136
233
  const files = collectFiles(buildDir).filter((f) => f.path !== "index.html");
137
234
  const entry = join(buildDir, "index.html");
138
235
  if (!existsSync(entry))
139
236
  throw new Error("The web build has no index.html. Run `npx expo export --platform web` and check it works.");
140
237
  files.push({ path: "app.html", bytes: new Uint8Array(readFileSync(entry)), contentType: typeOf("x.html") });
141
- files.push({ path: "index.html", bytes: new TextEncoder().encode(framePage(name)), contentType: typeOf("x.html") });
238
+ files.push({ path: "index.html", bytes: new TextEncoder().encode(framePage(name, appId)), contentType: typeOf("x.html") });
142
239
  return files;
143
240
  }
144
241
  const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -226,7 +323,7 @@ export async function runPreview(root, o = {}) {
226
323
  const dir = o.buildDir ?? join(root, ".shipvo", "preview-build");
227
324
  if (!o.skipBuild)
228
325
  await build(root, dir);
229
- return publishToHereNow(wrapBuild(dir, cfg.name), o);
326
+ return publishToHereNow(wrapBuild(dir, cfg.name, cfg.appId), o);
230
327
  }
231
328
  export async function preview(argv) {
232
329
  const a = parse(argv);
package/dist/promote.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { kv as kvFor } from "./config.js";
2
2
  type Store = Pick<ReturnType<typeof kvFor>, "get" | "put">;
3
3
  export declare const PROMOTE_FLAGS: string[];
4
- export declare const PROMOTE_HELP = "Usage: shipvo promote [flags]\n\nWiden a staged release to more bands. The release number, bundle and patches stay the same; the pointer is signed again.\n\nFlags (write a value after a space, not =):\n --to b1,b2 Bands to write (default: every band with no pointer for this release)\n Bands that already hold a newer release are always skipped\n --platform android|ios|all Platform to promote (default android)\n --dry-run Print the plan, write nothing\n --force Promote even if the release is halted or has reverts\n --help Show this help\n";
4
+ export declare const PROMOTE_HELP = "Usage: shipvo promote [flags]\n\nWiden a staged release to more bands. The release number, bundle and patches stay the same; the pointer is signed again.\n\nFlags (write a value after a space, not =):\n --to b1,b2 Bands to write (default: every band with no pointer for this release)\n Bands that already hold a newer release are always skipped\n --platform android|ios|all Platform to promote (default android)\n --dry-run Print the plan, write nothing\n --report <file> Test report from shipvo test --report (default shipvo-test-report.json if it exists)\n A report for this release with failing flows blocks promote\n --force Promote even if the release is halted, has reverts or has a failing test report\n --help Show this help\n";
5
5
  export declare function promote(argv: string[], deps?: {
6
6
  kv?: Store;
7
7
  fetchFn?: typeof fetch;
package/dist/promote.js CHANGED
@@ -1,11 +1,12 @@
1
1
  // `shipvo promote`: widen a staged release to more bands. Reads the pointer on the narrowest band that has one,
2
2
  // and writes the same release (same bundle, hashes and patches) to the other bands with a fresh issued_at and signature.
3
+ import { existsSync, readFileSync } from "node:fs";
3
4
  import { signBytes } from "./index.js";
4
5
  import { args as parse, kv as kvFor, loadConfig, seed } from "./config.js";
5
6
  const BANDS = ["b0", "b1", "b2"];
6
7
  const REVERT_EVENTS = ["rolled_back", "crash_loop_revert"];
7
8
  const pointerKey = (app, major, band, pf) => (pf === "ios" ? `p/${app}/${major}/ios/${band}` : `p/${app}/${major}/${band}`);
8
- export const PROMOTE_FLAGS = ["to", "platform", "dry-run", "force"];
9
+ export const PROMOTE_FLAGS = ["to", "platform", "dry-run", "force", "report"];
9
10
  export const PROMOTE_HELP = `Usage: shipvo promote [flags]
10
11
 
11
12
  Widen a staged release to more bands. The release number, bundle and patches stay the same; the pointer is signed again.
@@ -15,7 +16,9 @@ Flags (write a value after a space, not =):
15
16
  Bands that already hold a newer release are always skipped
16
17
  --platform android|ios|all Platform to promote (default android)
17
18
  --dry-run Print the plan, write nothing
18
- --force Promote even if the release is halted or has reverts
19
+ --report <file> Test report from shipvo test --report (default shipvo-test-report.json if it exists)
20
+ A report for this release with failing flows blocks promote
21
+ --force Promote even if the release is halted, has reverts or has a failing test report
19
22
  --help Show this help
20
23
  `;
21
24
  function checkFlags(argv, known) {
@@ -46,6 +49,9 @@ export async function promote(argv, deps = {}) {
46
49
  if (bad.length)
47
50
  throw new Error(`Unknown band ${bad.join(", ")}. Bands are ${BANDS.join(", ")}.`);
48
51
  }
52
+ if (a.report === "true")
53
+ throw new Error("--report needs a file name: pass --report <file>");
54
+ const reportFile = a.report ?? (existsSync("shipvo-test-report.json") ? "shipvo-test-report.json" : null);
49
55
  const APP = cfg.appId, MAJOR = cfg.shellMajor;
50
56
  const store = deps.kv ?? kvFor(cfg);
51
57
  // Read every source pointer before touching the network or writing anything.
@@ -78,6 +84,27 @@ export async function promote(argv, deps = {}) {
78
84
  const targets = cand.filter((b) => !((have.get(b) ?? 0) > release));
79
85
  plans.push({ pf, release, from, body, targets, newer });
80
86
  }
87
+ if (reportFile) {
88
+ let rep;
89
+ try {
90
+ rep = JSON.parse(readFileSync(reportFile, "utf8"));
91
+ }
92
+ catch (e) {
93
+ throw new Error(`Could not read the test report ${reportFile} (${e instanceof Error ? e.message : String(e)}).`);
94
+ }
95
+ const rel = rep.release == null ? null : String(rep.release);
96
+ const failing = (Array.isArray(rep.flows) ? rep.flows : []).filter((f) => f.status === "failed").map((f) => f.name ?? "(unnamed flow)");
97
+ const mine = plans.filter((x) => String(x.release) === rel);
98
+ if (mine.length && failing.length) {
99
+ const msg = `Test report ${reportFile} has ${failing.length} failing flow${failing.length === 1 ? "" : "s"} for release ${mine[0].release}: ${failing.join(", ")}.`;
100
+ if (!force)
101
+ throw new Error(`${msg} Fix them and run shipvo test --report again, or pass --force to promote anyway.`);
102
+ console.log(`${msg} Overridden by --force.`);
103
+ }
104
+ else if (!mine.length) {
105
+ console.log(`Warning: test report ${reportFile} is for ${rel === null ? "no release" : `release ${rel}`}, not release ${plans.map((x) => x.release).join(", ")}. It is stale and was not used.`);
106
+ }
107
+ }
81
108
  if (!force) {
82
109
  const base = cfg.controlPlane.replace(/\/$/, "");
83
110
  let data;
package/dist/publish.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const PUBLISH_HELP = "Usage: shipvo publish [flags]\n\nBuild, sign and publish an over-the-air release.\n\nFlags (write a value after a space, not =; on/off flags can be bare, or true/false):\n --bands b0,b1,b2 Cohorts to publish to (default b0,b1,b2)\n --platform android|ios|all Platform to publish (default android)\n --release N Release number (default: the next one)\n --min-shell X.Y.Z Oldest app shell that can run this release (default: version in app.json)\n --hermes-bytecode-version V Refuse bundles built for another Hermes version, for example 96 or 96,98\n --max-bases N How many older releases to build patches from (default 6)\n --skip-export Reuse the last build in dist-ota/\n --skip-web Do not publish the web copy for the simulator\n --dry-run Build and sign, write files locally, send nothing\n --out <dir> Where --dry-run writes files (default .shipvo/dry-run)\n --help Show this help\n";
1
+ export declare const PUBLISH_HELP = "Usage: shipvo publish [flags]\n\nBuild, sign and publish an over-the-air release.\n\nFlags (write a value after a space, not =; on/off flags can be bare, or true/false):\n --bands b0,b1,b2 Cohorts to publish to (default b0,b1,b2)\n --platform android|ios|all Platform to publish (default android)\n --release N Release number (default: the next one)\n --min-shell X.Y.Z Oldest app shell that can run this release (default: version in app.json)\n --hermes-bytecode-version V Refuse bundles built for another Hermes version, for example 96 or 96,98\n --max-bases N How many older releases to build patches from (default 6)\n --skip-export Reuse the last build in dist-ota/\n --skip-web Do not publish the web copy for the simulator\n --dry-run Build and sign, write files locally, send nothing\n --dry-run-policy Check the policy in shipvo.json for these --bands, print the result, publish nothing\n --out <dir> Where --dry-run writes files (default .shipvo/dry-run)\n --help Show this help\n";
2
2
  export declare function publish(argv: string[]): Promise<void>;
3
3
  /** Bytes in plain units: KB below 1 MB, so a small bundle never reads as 0.00 MB. */
4
4
  export declare function formatSize(bytes: number): string;