@shipvo/sim 0.1.3 → 0.1.4

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.
package/mock-rules.mjs ADDED
@@ -0,0 +1,28 @@
1
+ // Network mock rules sent by a test (`phone.mockNetwork` in @shipvo/test).
2
+ // The wire format here and the encoder in packages/test/src/phone.ts (encodeMockRules) must stay in sync.
3
+ // Wire rule: { url: string } or { urlRegex: { source, flags } }, optional method,
4
+ // and response: { status, body (string), headers (object of strings), delayMs }.
5
+ export class MockRuleError extends Error { constructor(message) { super(message); this.code = "mock_invalid"; } }
6
+
7
+ export function parseMockRules(raw) {
8
+ if (!Array.isArray(raw)) throw new MockRuleError("mock rules must be an array");
9
+ return raw.map((r, i) => {
10
+ const bad = (why) => new MockRuleError(`mock rule ${i}: ${why}`);
11
+ if (!r || typeof r !== "object") throw bad("must be an object");
12
+ let match;
13
+ if (typeof r.url === "string" && r.url) { const exact = r.url; match = (u) => u === exact; }
14
+ else if (r.urlRegex && typeof r.urlRegex.source === "string") {
15
+ let re; try { re = new RegExp(r.urlRegex.source, String(r.urlRegex.flags ?? "").replace(/[gy]/g, "")); } catch { throw bad("url is not a valid pattern"); }
16
+ match = (u) => re.test(u);
17
+ } else throw bad("url must be a non-empty string or a RegExp");
18
+ if (r.method !== undefined && (typeof r.method !== "string" || !r.method)) throw bad("method must be a non-empty string");
19
+ const resp = r.response;
20
+ if (!resp || typeof resp !== "object") throw bad("response is required");
21
+ if (!Number.isInteger(resp.status) || resp.status < 100 || resp.status > 599) throw bad("status must be an integer from 100 to 599");
22
+ if (typeof resp.body !== "string") throw bad("body must be a string");
23
+ if (resp.delayMs !== undefined && (typeof resp.delayMs !== "number" || !(resp.delayMs >= 0) || resp.delayMs > 60000)) throw bad("delayMs must be a number from 0 to 60000");
24
+ const headers = resp.headers ?? {};
25
+ if (typeof headers !== "object" || Array.isArray(headers) || Object.values(headers).some((v) => typeof v !== "string")) throw bad("headers must be an object of strings");
26
+ return { match, method: r.method?.toUpperCase(), status: resp.status, body: resp.body, headers, delayMs: resp.delayMs ?? 0 };
27
+ });
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipvo/sim",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "The Shipvo simulator: runs a Shipvo app's web build in a phone-sized headless browser and exposes it to agents (accessibility tree, tap/type/scroll, simulated device, mock backend). Used by @shipvo/mcp and `shipvo sim`.",
@@ -14,6 +14,8 @@
14
14
  "files": [
15
15
  "server.mjs",
16
16
  "app-name.mjs",
17
+ "mock-rules.mjs",
18
+ "push-notification.mjs",
17
19
  "README.md"
18
20
  ],
19
21
  "peerDependencies": {
@@ -0,0 +1,7 @@
1
+ // The `push_notification` action: hand a push to the device layer in the page and report whether the app got it.
2
+ // `__pnDevice.push` returns the notification id, or nothing when permission blocks it.
3
+ export async function pushNotification(page, body) {
4
+ const nid = await page.evaluate((n) => globalThis.__pnDevice?.push(n), { title: body.title ?? "", body: body.body ?? "", data: body.data ?? {} });
5
+ await page.waitForTimeout(200);
6
+ return { delivered: !!nid, id: nid ?? undefined };
7
+ }
package/server.mjs CHANGED
@@ -9,6 +9,8 @@ import { readFileSync, existsSync, writeFileSync, appendFileSync, mkdirSync, sta
9
9
  import { join, extname, dirname } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { withAppName } from "./app-name.mjs";
12
+ import { parseMockRules } from "./mock-rules.mjs";
13
+ import { pushNotification } from "./push-notification.mjs";
12
14
  import { createRequire } from "node:module";
13
15
  import { execSync } from "node:child_process";
14
16
  // Playwright: the app's own install first (its browsers match it), then this package's, then a global one.
@@ -40,7 +42,15 @@ async function resolveApp(spec) {
40
42
  }
41
43
  mkdirSync(DIR, { recursive: true });
42
44
  const MIME = { ".html": "text/html", ".js": "text/javascript", ".json": "application/json", ".png": "image/png", ".ico": "image/x-icon", ".ttf": "font/ttf", ".css": "text/css" };
43
- const browser = await chromium.launch();
45
+ // Chromium starts after the server is listening (see the end of this file). Handlers await this shared promise.
46
+ const LAUNCH_TIMEOUT_MS = 60_000;
47
+ let launchBrowser;
48
+ const browserReady = new Promise((resolve, reject) => { launchBrowser = () => {
49
+ const timer = setTimeout(() => reject(new Error(`Chromium did not start within ${LAUNCH_TIMEOUT_MS / 1000} s`)), LAUNCH_TIMEOUT_MS);
50
+ const executablePath = arg("chromium", undefined);
51
+ chromium.launch(executablePath ? { executablePath } : {}).then(resolve, reject).finally(() => clearTimeout(timer));
52
+ }; });
53
+ browserReady.catch((e) => { console.error(`sim: could not launch Chromium: ${e.message.split("\n")[0]}`); process.exit(1); });
44
54
  const sessions = new Map();
45
55
 
46
56
  const phone = { ...devices["Pixel 5"], viewport: { width: 393, height: 851 } };
@@ -49,6 +59,7 @@ async function newSession(id, opts = {}) {
49
59
  if (sessions.has(id)) await sessions.get(id).ctx.close();
50
60
  const target = await resolveApp(opts.app);
51
61
  writeFileSync(join(DIR, `${id}.json`), JSON.stringify({ orders: [] })); writeFileSync(join(DIR, `${id}.json.log`), "");
62
+ const browser = await browserReady;
52
63
  const ctx = await browser.newContext({ ...phone, colorScheme: opts.dark ? "dark" : "light" });
53
64
  await ctx.addInitScript((cfg) => { globalThis.__PN_SIM__ = cfg; }, { baseUrl: `http://localhost:${PORT}/s/${id}`, appId: target.app, appName: APP_NAME, seed_cart: opts.seed_cart ?? null, device: opts.device ?? null });
54
65
  const page = await ctx.newPage();
@@ -114,7 +125,7 @@ async function screen(page) {
114
125
  function log(id, action, ok, extra = {}) { appendFileSync(join(DIR, `${id}.json.log`), JSON.stringify({ at: Date.now(), name: action, ok, ...extra }) + "\n"); }
115
126
 
116
127
  async function act(id, body) {
117
- const s = sessions.get(id); if (!s) throw new Error(`no session '${id}': call /session first`);
128
+ const s = sessions.get(id); if (!s) throw new SimError("no_session", `no session '${id}': call /session first`);
118
129
  const { page } = s;
119
130
  // A ref is valid only for the page it came from and while its element is unchanged. Re-snapshot first, so a node that
120
131
  // was re-rendered with new content has lost its old ref, then fail with a typed error instead of acting on anything else.
@@ -129,9 +140,26 @@ async function act(id, body) {
129
140
  if ((await l.count()) !== 1) throw stale();
130
141
  return l;
131
142
  };
143
+ // Before acting on an element, say why it cannot receive the action. Nothing has reached the app when this throws.
144
+ const NOT_DELIVERED = { not_visible: "is not visible", disabled: "is disabled", covered: "is covered by another element" };
145
+ const ready = async (l) => {
146
+ const fail = (reason, what) => new SimError(reason, `${what} ${NOT_DELIVERED[reason]}, so nothing was sent to the app.`);
147
+ if (!(await l.isVisible())) throw fail("not_visible", String(body.ref));
148
+ // Still off the screen after scrolling it into view (for example a fixed element parked outside the window).
149
+ await l.scrollIntoViewIfNeeded({ timeout: 1000 }).catch(() => {});
150
+ const box = await l.boundingBox(); const vp = page.viewportSize();
151
+ if (!box || (vp && (box.x + box.width <= 0 || box.y + box.height <= 0 || box.x >= vp.width || box.y >= vp.height))) throw fail("not_visible", String(body.ref));
152
+ if (await l.isDisabled()) throw fail("disabled", String(body.ref));
153
+ const covered = await l.evaluate((el) => {
154
+ const r = el.getBoundingClientRect();
155
+ const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
156
+ return !!top && top !== el && !el.contains(top) && !top.contains(el);
157
+ });
158
+ if (covered) throw fail("covered", String(body.ref));
159
+ };
132
160
  switch (body.action) {
133
161
  case "screen": break;
134
- case "tap": await (await resolve(body.ref)).click({ timeout: 3000 }); break;
162
+ case "tap": { const t = await resolve(body.ref); await ready(t); await t.click({ timeout: 3000 }); break; }
135
163
  case "type": {
136
164
  // A labelled text field may resolve to its wrapper; type into the real input inside it, as a phone focuses the field.
137
165
  let t = await resolve(body.ref);
@@ -143,7 +171,9 @@ async function act(id, body) {
143
171
  if ((await named.count()) !== 1) throw new Error(`${body.ref} is not a text field. Type into a "textbox" ref.`);
144
172
  t = named;
145
173
  }
146
- await t.fill(String(body.text ?? ""), { timeout: 3000 }); if (body.enter) await t.press("Enter"); break;
174
+ await ready(t);
175
+ await t.fill(String(body.text ?? ""), { timeout: 3000 }); if (body.enter) await t.press("Enter");
176
+ return { delivery: "delivered", value: await t.inputValue().catch(() => null), screen: await screen(page) };
147
177
  }
148
178
  case "scroll": {
149
179
  const target = body.ref ? await resolve(body.ref) : page.locator("body");
@@ -154,9 +184,33 @@ async function act(id, body) {
154
184
  }
155
185
  // Simulated device: what the phone "has" and what happens to it. See @shipvo/device.
156
186
  case "set_device": await page.evaluate((d) => globalThis.__pnDevice?.set(d), body.device ?? {}); break;
157
- case "push_notification": await page.evaluate((n) => globalThis.__pnDevice?.push(n), { title: body.title ?? "", body: body.body ?? "", data: body.data ?? {} }); break;
187
+ case "push_notification": return { ...(await pushNotification(page, body)), screen: await screen(page) };
158
188
  case "notifications": return { notifications: await page.evaluate(() => globalThis.__pnDevice?.notifications() ?? []), screen: await screen(page) };
159
189
  case "open_notification": { const ok = await page.evaluate((nid) => globalThis.__pnDevice?.openNotification(nid), body.id); if (!ok) throw new Error("no such notification"); await page.waitForTimeout(400); break; }
190
+ case "open_url": {
191
+ if (typeof body.url !== "string" || !body.url) throw new SimError("bad_url", "open_url needs a url, such as myapp://item/3");
192
+ const handled = await page.evaluate((u) => globalThis.__pnDevice?.openUrl(u), body.url);
193
+ if (!handled) throw new SimError("no_url_listener", `the app has no URL listener, so it did not handle ${body.url}. Register one with onUrl from @shipvo/device.`);
194
+ await page.waitForTimeout(400); break;
195
+ }
196
+ // Stub fetch/XHR on the page. Rules live on the session; unmatched requests continue untouched.
197
+ case "set_network_mocks": {
198
+ let rules; try { rules = parseMockRules(body.rules ?? []); } catch (e) { throw new SimError(e.code ?? "mock_invalid", e.message); }
199
+ s.mocks = rules;
200
+ if (!s.mockRoute) {
201
+ s.mockRoute = true;
202
+ await page.route("**/*", async (route) => {
203
+ const req = route.request();
204
+ const type = req.resourceType();
205
+ const rule = (type === "fetch" || type === "xhr") && (s.mocks ?? []).find((m) => (!m.method || m.method === req.method()) && m.match(req.url()));
206
+ if (!rule) return route.fallback();
207
+ if (rule.delayMs) await new Promise((r) => setTimeout(r, rule.delayMs));
208
+ // The app page may come from another origin, so the stub must pass the browser's CORS check.
209
+ await route.fulfill({ status: rule.status, body: rule.body, headers: { "access-control-allow-origin": "*", ...rule.headers } }).catch(() => {});
210
+ });
211
+ }
212
+ break;
213
+ }
160
214
  case "device_log": return { log: await page.evaluate(() => globalThis.__pnDevice?.log() ?? []), capabilities: await page.evaluate(() => globalThis.__pnDevice?.capabilities() ?? null), screen: await screen(page) };
161
215
  case "diag": {
162
216
  const r = await page.evaluate(async () => {
@@ -168,13 +222,15 @@ async function act(id, body) {
168
222
  return { diag: r, screen: await screen(page) };
169
223
  }
170
224
  case "device_reset": await page.evaluate((d) => globalThis.__pnDevice?.reset(d), body.device ?? {}); break;
171
- case "back": { const b = page.getByRole("button", { name: "Back", exact: true }); if (await b.count()) await b.first().click(); else throw new Error("no Back button on this screen"); break; }
225
+ case "back": { const b = page.getByRole("button", { name: "Back", exact: true }); if (await b.count()) { await ready(b.first()); await b.first().click(); } else throw new Error("no Back button on this screen"); break; }
172
226
  case "screenshot": { const p = join(DIR, `${id}-${Date.now()}.png`); await page.screenshot({ path: p }); return { path: p, screen: await screen(page) }; }
173
- default: throw new Error(`unknown action '${body.action}'. Actions: screen, tap, type, scroll, back, screenshot, set_device, push_notification, notifications, open_notification, device_log, device_reset`);
227
+ default: throw new Error(`unknown action '${body.action}'. Actions: screen, tap, type, scroll, back, screenshot, set_device, push_notification, notifications, open_notification, open_url, device_log, device_reset, set_network_mocks`);
174
228
  }
175
- return { screen: await screen(page) };
229
+ return { ...(["tap", "scroll", "back"].includes(body.action) ? { delivery: "delivered" } : {}), screen: await screen(page) };
176
230
  }
177
231
 
232
+ const DELIVERY = new Set(["tap", "type", "scroll", "back"]);
233
+ const REASONS = new Set(["stale_ref", "not_visible", "disabled", "covered"]);
178
234
  const WCACHE = new Map();
179
235
  const server = http.createServer(async (req, res) => {
180
236
  const url = new URL(req.url, "http://x");
@@ -194,6 +250,8 @@ const server = http.createServer(async (req, res) => {
194
250
  if (!hit) { const r = await fetch(`${CONTROL}${key}`); if (!r.ok) return send(r.status, { error: "artifact" }); hit = { type: r.headers.get("content-type") ?? "application/octet-stream", body: Buffer.from(await r.arrayBuffer()) }; WCACHE.set(key, hit); }
195
251
  return send(200, hit.body, hit.type);
196
252
  }
253
+ // a founder opening the printed link lands on the app
254
+ if ((req.method === "GET" || req.method === "HEAD") && url.pathname === "/") { res.writeHead(302, { location: "/app/", ...CORS }); return res.end(); }
197
255
  if (url.pathname.startsWith("/app/")) {
198
256
  let p = join(DIST, url.pathname.slice(5) || "index.html"); if (!existsSync(p) || statSync(p).isDirectory()) p = join(DIST, "index.html");
199
257
  if (extname(p) === ".html") return send(200, withAppName(readFileSync(p, "utf8"), APP_NAME), MIME[".html"]);
@@ -224,7 +282,7 @@ const server = http.createServer(async (req, res) => {
224
282
  if (url.pathname === "/session") { await newSession(body.session, body); const s = sessions.get(body.session); log(body.session, "start", true, { app: s.app, release: s.release }); return send(200, { ok: true, app: s.app, release: s.release, screen: await screen(s.page) }); }
225
283
  if (url.pathname === "/act") {
226
284
  try { const r = await act(body.session, body); log(body.session, body.action, true, body.ref ? { ref: body.ref } : {}); return send(200, { ok: true, ...r }); }
227
- catch (e) { log(body.session, body.action, false); const s = sessions.get(body.session); return send(200, { ok: false, error: String(e.message).split("\n")[0], ...(e.code ? { code: e.code } : {}), screen: s ? await screen(s.page) : null }); }
285
+ catch (e) { log(body.session, body.action, false); const s = sessions.get(body.session); return send(200, { ok: false, error: String(e.message).split("\n")[0], ...(e.code ? { code: e.code } : {}), ...(DELIVERY.has(body.action) && REASONS.has(e.code) ? { delivery: "not_delivered", reason: e.code, safe_to_retry: true } : {}), screen: s ? await screen(s.page) : null }); }
228
286
  }
229
287
  send(404, { error: "not found" });
230
288
  } catch (e) { send(500, { error: String(e) }); }
@@ -232,3 +290,4 @@ const server = http.createServer(async (req, res) => {
232
290
  // Agents and parity runs pause between actions; keep idle connections open well beyond Node's 5 s default.
233
291
  server.keepAliveTimeout = 300_000; server.headersTimeout = 310_000; server.requestTimeout = 0;
234
292
  server.listen(PORT, () => console.log(`Shipvo UI simulator on http://localhost:${PORT} (sessions in ${DIR})`));
293
+ launchBrowser();