@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 +8 -0
- package/mock-rules.mjs +28 -0
- package/package.json +7 -1
- package/push-notification.mjs +7 -0
- package/server.mjs +101 -13
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
const { page } = s;
|
|
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
|
|
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 =
|
|
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
|
|
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 ?
|
|
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":
|
|
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();
|