@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 +28 -0
- package/package.json +3 -1
- package/push-notification.mjs +7 -0
- package/server.mjs +68 -9
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
|
+
"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
|
-
|
|
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
|
|
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":
|
|
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
|
|
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":
|
|
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();
|