@shipvo/sim 0.1.1 → 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 +8 -0
- package/package.json +12 -3
- package/server.mjs +35 -6
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.
|
|
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": {
|
|
@@ -20,10 +21,15 @@
|
|
|
20
21
|
},
|
|
21
22
|
"keywords": [
|
|
22
23
|
"shipvo",
|
|
23
|
-
"
|
|
24
|
+
"react-native",
|
|
24
25
|
"react-native-web",
|
|
26
|
+
"mobile-simulator",
|
|
27
|
+
"headless",
|
|
25
28
|
"playwright",
|
|
26
|
-
"
|
|
29
|
+
"mcp",
|
|
30
|
+
"ai-agents",
|
|
31
|
+
"mobile-testing",
|
|
32
|
+
"e2e-testing"
|
|
27
33
|
],
|
|
28
34
|
"homepage": "https://github.com/shipvo/shipvo#readme",
|
|
29
35
|
"repository": {
|
|
@@ -37,5 +43,8 @@
|
|
|
37
43
|
},
|
|
38
44
|
"devDependencies": {
|
|
39
45
|
"playwright": "1.63.0"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "node --test"
|
|
40
49
|
}
|
|
41
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
|
-
|
|
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;
|
|
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
|
|
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 =
|
|
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 ?
|
|
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) }); }
|