@shipvo/sim 0.1.2 → 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/app-name.mjs ADDED
@@ -0,0 +1,8 @@
1
+ // A page opened straight in a browser (not through a simulator session) has no __PN_SIM__ from the session,
2
+ // so the simulated device would call the app "This app". Put the app's name from shipvo.json in the page.
3
+ // A session's own value wins: the script only fills in what is missing.
4
+ export function withAppName(html, name) {
5
+ const json = JSON.stringify(String(name)).replace(/</g, "\\u003c");
6
+ const tag = `<script>globalThis.__PN_SIM__=globalThis.__PN_SIM__||{};globalThis.__PN_SIM__.appName=globalThis.__PN_SIM__.appName||${json};</script>`;
7
+ return /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, (m) => m + tag) : tag + html;
8
+ }
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.2",
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`.",
@@ -13,6 +13,9 @@
13
13
  },
14
14
  "files": [
15
15
  "server.mjs",
16
+ "app-name.mjs",
17
+ "mock-rules.mjs",
18
+ "push-notification.mjs",
16
19
  "README.md"
17
20
  ],
18
21
  "peerDependencies": {
@@ -42,5 +45,8 @@
42
45
  },
43
46
  "devDependencies": {
44
47
  "playwright": "1.63.0"
48
+ },
49
+ "scripts": {
50
+ "test": "node --test"
45
51
  }
46
52
  }
@@ -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
@@ -8,6 +8,9 @@ import http from "node:http";
8
8
  import { readFileSync, existsSync, writeFileSync, appendFileSync, mkdirSync, statSync } from "node:fs";
9
9
  import { join, extname, dirname } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
+ import { withAppName } from "./app-name.mjs";
12
+ import { parseMockRules } from "./mock-rules.mjs";
13
+ import { pushNotification } from "./push-notification.mjs";
11
14
  import { createRequire } from "node:module";
12
15
  import { execSync } from "node:child_process";
13
16
  // Playwright: the app's own install first (its browsers match it), then this package's, then a global one.
@@ -39,7 +42,15 @@ async function resolveApp(spec) {
39
42
  }
40
43
  mkdirSync(DIR, { recursive: true });
41
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" };
42
- 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); });
43
54
  const sessions = new Map();
44
55
 
45
56
  const phone = { ...devices["Pixel 5"], viewport: { width: 393, height: 851 } };
@@ -48,6 +59,7 @@ async function newSession(id, opts = {}) {
48
59
  if (sessions.has(id)) await sessions.get(id).ctx.close();
49
60
  const target = await resolveApp(opts.app);
50
61
  writeFileSync(join(DIR, `${id}.json`), JSON.stringify({ orders: [] })); writeFileSync(join(DIR, `${id}.json.log`), "");
62
+ const browser = await browserReady;
51
63
  const ctx = await browser.newContext({ ...phone, colorScheme: opts.dark ? "dark" : "light" });
52
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 });
53
65
  const page = await ctx.newPage();
@@ -56,8 +68,20 @@ async function newSession(id, opts = {}) {
56
68
  sessions.set(id, { ctx, page, app: target.app, release: target.release, startedAt: new Date().toISOString() });
57
69
  }
58
70
 
71
+ // Refs are only meaningful for the document that issued them: a full page load restarts Playwright's counter, so an
72
+ // old "e5" would point at a different element. Each document gets an epoch; refs after the first load are "p<epoch>e5".
73
+ const DOCS = new WeakMap();
74
+ async function epochOf(page) {
75
+ const id = await page.evaluate(() => (globalThis.__pnDoc ??= Math.random().toString(36).slice(2)));
76
+ const d = DOCS.get(page);
77
+ if (!d) DOCS.set(page, { id, epoch: 0 }); else if (d.id !== id) { d.id = id; d.epoch++; }
78
+ return DOCS.get(page).epoch;
79
+ }
80
+ class SimError extends Error { constructor(code, message) { super(message); this.code = code; } }
81
+
59
82
  async function screen(page) {
60
83
  await page.waitForTimeout(150);
84
+ const epoch = await epochOf(page);
61
85
  // Phones only expose the items of a horizontal list that are on screen; hide the rest, as a phone would,
62
86
  // so an agent learns to swipe the rail ("scroll right <ref>") exactly as it must on a device.
63
87
  await page.evaluate(() => {
@@ -94,20 +118,51 @@ async function screen(page) {
94
118
  out.push(l);
95
119
  if (/^\s*- (button|radio|link|checkbox|switch|tab) "[^"]+"/.test(l) && /:\s*$/.test(l)) hideBelow = indent;
96
120
  }
97
- return out.join("\n");
121
+ const text = out.join("\n");
122
+ return epoch ? text.replace(/\[ref=(e\d+)\]/g, `[ref=p${epoch}$1]`) : text;
98
123
  }
99
124
 
100
125
  function log(id, action, ok, extra = {}) { appendFileSync(join(DIR, `${id}.json.log`), JSON.stringify({ at: Date.now(), name: action, ok, ...extra }) + "\n"); }
101
126
 
102
127
  async function act(id, body) {
103
- const s = sessions.get(id); if (!s) throw new Error(`no session '${id}': call /session first`);
104
- const { page } = s; const loc = (ref) => page.locator(`aria-ref=${ref}`);
128
+ const s = sessions.get(id); if (!s) throw new SimError("no_session", `no session '${id}': call /session first`);
129
+ const { page } = s;
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
131
+ // was re-rendered with new content has lost its old ref, then fail with a typed error instead of acting on anything else.
132
+ const loc = (ref) => page.locator(`aria-ref=${ref}`);
133
+ const resolve = async (ref) => {
134
+ const m = /^(?:p(\d+))?(e\d+)$/.exec(String(ref));
135
+ const stale = () => new SimError("stale_ref", `stale ref ${ref}: the screen changed. Take a new snapshot (action "screen") and use a ref from it.`);
136
+ if (!m) return loc(ref);
137
+ await screen(page);
138
+ if (Number(m[1] ?? 0) !== DOCS.get(page).epoch) throw stale();
139
+ const l = loc(m[2]);
140
+ if ((await l.count()) !== 1) throw stale();
141
+ return l;
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
+ };
105
160
  switch (body.action) {
106
161
  case "screen": break;
107
- case "tap": await loc(body.ref).click({ timeout: 3000 }); break;
162
+ case "tap": { const t = await resolve(body.ref); await ready(t); await t.click({ timeout: 3000 }); break; }
108
163
  case "type": {
109
164
  // A labelled text field may resolve to its wrapper; type into the real input inside it, as a phone focuses the field.
110
- let t = loc(body.ref);
165
+ let t = await resolve(body.ref);
111
166
  const kind = await t.evaluate((el) => (/^(INPUT|TEXTAREA)$/.test(el.tagName) || el.isContentEditable) ? "field" : el.querySelector("input, textarea") ? "wrapper" : (el.getAttribute("aria-label") || el.textContent || "").trim(), { timeout: 3000 });
112
167
  if (kind === "wrapper") t = t.locator("input, textarea").first();
113
168
  else if (kind !== "field") {
@@ -116,10 +171,12 @@ async function act(id, body) {
116
171
  if ((await named.count()) !== 1) throw new Error(`${body.ref} is not a text field. Type into a "textbox" ref.`);
117
172
  t = named;
118
173
  }
119
- 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) };
120
177
  }
121
178
  case "scroll": {
122
- const target = body.ref ? loc(body.ref) : page.locator("body");
179
+ const target = body.ref ? await resolve(body.ref) : page.locator("body");
123
180
  const box = await target.boundingBox().catch(() => null);
124
181
  if (box) await page.mouse.move(box.x + box.width / 2, box.y + Math.min(box.height / 2, 400));
125
182
  await page.mouse.wheel(body.direction === "right" ? 300 : body.direction === "left" ? -300 : 0, body.direction === "up" ? -600 : body.direction === "down" ? 600 : 0);
@@ -127,9 +184,33 @@ async function act(id, body) {
127
184
  }
128
185
  // Simulated device: what the phone "has" and what happens to it. See @shipvo/device.
129
186
  case "set_device": await page.evaluate((d) => globalThis.__pnDevice?.set(d), body.device ?? {}); break;
130
- 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) };
131
188
  case "notifications": return { notifications: await page.evaluate(() => globalThis.__pnDevice?.notifications() ?? []), screen: await screen(page) };
132
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
+ }
133
214
  case "device_log": return { log: await page.evaluate(() => globalThis.__pnDevice?.log() ?? []), capabilities: await page.evaluate(() => globalThis.__pnDevice?.capabilities() ?? null), screen: await screen(page) };
134
215
  case "diag": {
135
216
  const r = await page.evaluate(async () => {
@@ -141,13 +222,15 @@ async function act(id, body) {
141
222
  return { diag: r, screen: await screen(page) };
142
223
  }
143
224
  case "device_reset": await page.evaluate((d) => globalThis.__pnDevice?.reset(d), body.device ?? {}); break;
144
- 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; }
145
226
  case "screenshot": { const p = join(DIR, `${id}-${Date.now()}.png`); await page.screenshot({ path: p }); return { path: p, screen: await screen(page) }; }
146
- 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`);
147
228
  }
148
- return { screen: await screen(page) };
229
+ return { ...(["tap", "scroll", "back"].includes(body.action) ? { delivery: "delivered" } : {}), screen: await screen(page) };
149
230
  }
150
231
 
232
+ const DELIVERY = new Set(["tap", "type", "scroll", "back"]);
233
+ const REASONS = new Set(["stale_ref", "not_visible", "disabled", "covered"]);
151
234
  const WCACHE = new Map();
152
235
  const server = http.createServer(async (req, res) => {
153
236
  const url = new URL(req.url, "http://x");
@@ -167,8 +250,11 @@ const server = http.createServer(async (req, res) => {
167
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); }
168
251
  return send(200, hit.body, hit.type);
169
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(); }
170
255
  if (url.pathname.startsWith("/app/")) {
171
256
  let p = join(DIST, url.pathname.slice(5) || "index.html"); if (!existsSync(p) || statSync(p).isDirectory()) p = join(DIST, "index.html");
257
+ if (extname(p) === ".html") return send(200, withAppName(readFileSync(p, "utf8"), APP_NAME), MIME[".html"]);
172
258
  return send(200, readFileSync(p), MIME[extname(p)] ?? "application/octet-stream");
173
259
  }
174
260
  // mock control plane per session
@@ -191,11 +277,12 @@ const server = http.createServer(async (req, res) => {
191
277
  // agent API
192
278
  if (req.method !== "POST") return send(404, { error: "not found" });
193
279
  let raw = ""; for await (const ch of req) raw += ch; const body = raw ? JSON.parse(raw) : {};
280
+ if (url.pathname === "/apps") return send(200, { ok: true, apps: [{ id: DEFAULT_APP, name: APP_NAME }] });
194
281
  if (url.pathname === "/sessions") return send(200, { ok: true, sessions: [...sessions.entries()].map(([id, s]) => ({ session: id, app: s.app, release: s.release, started_at: s.startedAt ?? null, url: s.page.url() })) });
195
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) }); }
196
283
  if (url.pathname === "/act") {
197
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 }); }
198
- 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], 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 }); }
199
286
  }
200
287
  send(404, { error: "not found" });
201
288
  } catch (e) { send(500, { error: String(e) }); }
@@ -203,3 +290,4 @@ const server = http.createServer(async (req, res) => {
203
290
  // Agents and parity runs pause between actions; keep idle connections open well beyond Node's 5 s default.
204
291
  server.keepAliveTimeout = 300_000; server.headersTimeout = 310_000; server.requestTimeout = 0;
205
292
  server.listen(PORT, () => console.log(`Shipvo UI simulator on http://localhost:${PORT} (sessions in ${DIR})`));
293
+ launchBrowser();