@shipvo/sim 0.1.2 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipvo/sim",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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,7 @@
13
13
  },
14
14
  "files": [
15
15
  "server.mjs",
16
+ "app-name.mjs",
16
17
  "README.md"
17
18
  ],
18
19
  "peerDependencies": {
@@ -42,5 +43,8 @@
42
43
  },
43
44
  "devDependencies": {
44
45
  "playwright": "1.63.0"
46
+ },
47
+ "scripts": {
48
+ "test": "node --test"
45
49
  }
46
50
  }
package/server.mjs CHANGED
@@ -8,6 +8,7 @@ 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";
11
12
  import { createRequire } from "node:module";
12
13
  import { execSync } from "node:child_process";
13
14
  // Playwright: the app's own install first (its browsers match it), then this package's, then a global one.
@@ -56,8 +57,20 @@ async function newSession(id, opts = {}) {
56
57
  sessions.set(id, { ctx, page, app: target.app, release: target.release, startedAt: new Date().toISOString() });
57
58
  }
58
59
 
60
+ // Refs are only meaningful for the document that issued them: a full page load restarts Playwright's counter, so an
61
+ // old "e5" would point at a different element. Each document gets an epoch; refs after the first load are "p<epoch>e5".
62
+ const DOCS = new WeakMap();
63
+ async function epochOf(page) {
64
+ const id = await page.evaluate(() => (globalThis.__pnDoc ??= Math.random().toString(36).slice(2)));
65
+ const d = DOCS.get(page);
66
+ if (!d) DOCS.set(page, { id, epoch: 0 }); else if (d.id !== id) { d.id = id; d.epoch++; }
67
+ return DOCS.get(page).epoch;
68
+ }
69
+ class SimError extends Error { constructor(code, message) { super(message); this.code = code; } }
70
+
59
71
  async function screen(page) {
60
72
  await page.waitForTimeout(150);
73
+ const epoch = await epochOf(page);
61
74
  // Phones only expose the items of a horizontal list that are on screen; hide the rest, as a phone would,
62
75
  // so an agent learns to swipe the rail ("scroll right <ref>") exactly as it must on a device.
63
76
  await page.evaluate(() => {
@@ -94,20 +107,34 @@ async function screen(page) {
94
107
  out.push(l);
95
108
  if (/^\s*- (button|radio|link|checkbox|switch|tab) "[^"]+"/.test(l) && /:\s*$/.test(l)) hideBelow = indent;
96
109
  }
97
- return out.join("\n");
110
+ const text = out.join("\n");
111
+ return epoch ? text.replace(/\[ref=(e\d+)\]/g, `[ref=p${epoch}$1]`) : text;
98
112
  }
99
113
 
100
114
  function log(id, action, ok, extra = {}) { appendFileSync(join(DIR, `${id}.json.log`), JSON.stringify({ at: Date.now(), name: action, ok, ...extra }) + "\n"); }
101
115
 
102
116
  async function act(id, body) {
103
117
  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}`);
118
+ const { page } = s;
119
+ // 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
+ // was re-rendered with new content has lost its old ref, then fail with a typed error instead of acting on anything else.
121
+ const loc = (ref) => page.locator(`aria-ref=${ref}`);
122
+ const resolve = async (ref) => {
123
+ const m = /^(?:p(\d+))?(e\d+)$/.exec(String(ref));
124
+ const stale = () => new SimError("stale_ref", `stale ref ${ref}: the screen changed. Take a new snapshot (action "screen") and use a ref from it.`);
125
+ if (!m) return loc(ref);
126
+ await screen(page);
127
+ if (Number(m[1] ?? 0) !== DOCS.get(page).epoch) throw stale();
128
+ const l = loc(m[2]);
129
+ if ((await l.count()) !== 1) throw stale();
130
+ return l;
131
+ };
105
132
  switch (body.action) {
106
133
  case "screen": break;
107
- case "tap": await loc(body.ref).click({ timeout: 3000 }); break;
134
+ case "tap": await (await resolve(body.ref)).click({ timeout: 3000 }); break;
108
135
  case "type": {
109
136
  // 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);
137
+ let t = await resolve(body.ref);
111
138
  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
139
  if (kind === "wrapper") t = t.locator("input, textarea").first();
113
140
  else if (kind !== "field") {
@@ -119,7 +146,7 @@ async function act(id, body) {
119
146
  await t.fill(String(body.text ?? ""), { timeout: 3000 }); if (body.enter) await t.press("Enter"); break;
120
147
  }
121
148
  case "scroll": {
122
- const target = body.ref ? loc(body.ref) : page.locator("body");
149
+ const target = body.ref ? await resolve(body.ref) : page.locator("body");
123
150
  const box = await target.boundingBox().catch(() => null);
124
151
  if (box) await page.mouse.move(box.x + box.width / 2, box.y + Math.min(box.height / 2, 400));
125
152
  await page.mouse.wheel(body.direction === "right" ? 300 : body.direction === "left" ? -300 : 0, body.direction === "up" ? -600 : body.direction === "down" ? 600 : 0);
@@ -169,6 +196,7 @@ const server = http.createServer(async (req, res) => {
169
196
  }
170
197
  if (url.pathname.startsWith("/app/")) {
171
198
  let p = join(DIST, url.pathname.slice(5) || "index.html"); if (!existsSync(p) || statSync(p).isDirectory()) p = join(DIST, "index.html");
199
+ if (extname(p) === ".html") return send(200, withAppName(readFileSync(p, "utf8"), APP_NAME), MIME[".html"]);
172
200
  return send(200, readFileSync(p), MIME[extname(p)] ?? "application/octet-stream");
173
201
  }
174
202
  // mock control plane per session
@@ -191,11 +219,12 @@ const server = http.createServer(async (req, res) => {
191
219
  // agent API
192
220
  if (req.method !== "POST") return send(404, { error: "not found" });
193
221
  let raw = ""; for await (const ch of req) raw += ch; const body = raw ? JSON.parse(raw) : {};
222
+ if (url.pathname === "/apps") return send(200, { ok: true, apps: [{ id: DEFAULT_APP, name: APP_NAME }] });
194
223
  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
224
  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
225
  if (url.pathname === "/act") {
197
226
  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 }); }
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 }); }
199
228
  }
200
229
  send(404, { error: "not found" });
201
230
  } catch (e) { send(500, { error: String(e) }); }