@shipvo/sim 0.1.4 → 0.1.6

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.
@@ -0,0 +1,45 @@
1
+ // The rules that decide what is "on screen" for an agent. Plain ES module, no imports, so the simulator
2
+ // (`server.mjs`) and the `shipvo run` frame page share one copy. The DOM functions are self-contained:
3
+ // they call no other function, so they can be serialised and run inside a page with `(${fn})(document)`.
4
+
5
+ /** Remove the markers that `applyScreenRules` set. */
6
+ export function clearScreenRules(document) {
7
+ for (const el of document.querySelectorAll("[data-pn-offscreen]")) { el.removeAttribute("aria-hidden"); el.removeAttribute("data-pn-offscreen"); }
8
+ }
9
+
10
+ /** Call `clearScreenRules` first. Hide what a phone would not expose: content behind a modal sheet and off-screen items of a horizontal list. */
11
+ export function applyScreenRules(document) {
12
+ const win = document.defaultView;
13
+ // A modal sheet (accessibilityViewIsModal / aria-modal) hides everything behind it from assistive tech on phones.
14
+ const modal = [...document.querySelectorAll('[aria-modal="true"]')].pop();
15
+ if (modal) for (let node = modal; node && node !== document.body; node = node.parentElement)
16
+ for (const sib of node.parentElement?.children ?? []) if (sib !== node && !sib.hasAttribute("aria-hidden")) { sib.setAttribute("aria-hidden", "true"); sib.setAttribute("data-pn-offscreen", "1"); }
17
+ // Phones only expose the items of a horizontal list that are on screen.
18
+ for (const sc of document.querySelectorAll("div")) {
19
+ const cs = win.getComputedStyle(sc);
20
+ if (!/(auto|scroll)/.test(cs.overflowX) || sc.scrollWidth <= sc.clientWidth + 1) continue;
21
+ const box = sc.getBoundingClientRect();
22
+ const row = sc.firstElementChild; if (!row) continue;
23
+ for (const item of row.children) {
24
+ const r = item.getBoundingClientRect();
25
+ if (r.right <= box.left + 1 || r.left >= box.right - 1) { item.setAttribute("aria-hidden", "true"); item.setAttribute("data-pn-offscreen", "1"); }
26
+ }
27
+ }
28
+ }
29
+
30
+ /** Post-process an accessibility snapshot: drop empty generic lines, `aria-hidden` subtrees and the inner text of labelled controls. */
31
+ export function filterTree(tree) {
32
+ const lines = tree.split("\n").filter((l) => !/^\s*- generic \[ref=e\d+\]:?\s*$/.test(l));
33
+ // A screen reader announces a control's label, not the words inside it (Android and iOS group a pressable's
34
+ // children into one element). Drop the inner text lines of labelled controls, as the phones do.
35
+ const out = []; let hideBelow = -1;
36
+ for (const l of lines) {
37
+ const indent = l.search(/\S/);
38
+ if (hideBelow >= 0 && indent > hideBelow) continue;
39
+ hideBelow = -1;
40
+ if (/\[aria-hidden\]/.test(l)) { hideBelow = indent; continue; } // hidden from assistive tech: not on screen
41
+ out.push(l);
42
+ if (/^\s*- (button|radio|link|checkbox|switch|tab) "[^"]+"/.test(l) && /:\s*$/.test(l)) hideBelow = indent;
43
+ }
44
+ return out.join("\n");
45
+ }
package/server.mjs CHANGED
@@ -10,14 +10,29 @@ import { join, extname, dirname } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { withAppName } from "./app-name.mjs";
12
12
  import { parseMockRules } from "./mock-rules.mjs";
13
+ import { applyProfile, getProfile } from "./network-profiles.mjs";
13
14
  import { pushNotification } from "./push-notification.mjs";
14
15
  import { createRequire } from "node:module";
15
16
  import { execSync } from "node:child_process";
17
+ import { resolveBrowser } from "./browser-resolver.mjs";
18
+ import { PhoneBrowser, BrowserError, checkUrl } from "./phone-browser.mjs";
19
+ import { acceptWebSocket } from "./ws-lite.mjs";
20
+ import { browserAppPage } from "./browser-app.mjs";
21
+ import { launchErrorMessage } from "./chromium-hint.mjs";
22
+ import { shadeAction, SHADE_ACTIONS } from "./shade-actions.mjs";
23
+ import { settingsAction, syncMedia, SETTINGS_ACTIONS } from "./settings-actions.mjs";
24
+ import { SettingsError } from "./settings.mjs";
25
+ import { AppsError, loadProfile, profilePatch, installApp, uninstallApp, switchApp, describeApps } from "./apps.mjs";
26
+ import { UpdateError, createUpdate, checkForUpdate, applyUpdate, bootDone, updateView, bannerText } from "./update.mjs";
27
+ import { ShadeError } from "./shade.mjs";
28
+ import { createNav, pressHome, openRecents, resume, killApp, assertForeground, setVisibilityInPage, awayScreenHtml, NavError } from "./nav.mjs";
29
+ import { createLifecycle, lock, unlock, restart, assertUnlocked, LifecycleError } from "./lifecycle.mjs";
30
+ import { applyScreenRules, clearScreenRules, filterTree } from "./screen-rules.mjs";
16
31
  // Playwright: the app's own install first (its browsers match it), then this package's, then a global one.
17
32
  const require_ = createRequire(import.meta.url);
18
- let pw;
19
- for (const get of [() => createRequire(join(process.cwd(), "package.json"))("playwright"), () => require_("playwright"), () => require_(join(execSync("npm root -g").toString().trim(), "playwright"))]) {
20
- try { pw = get(); break; } catch {}
33
+ let pw, pwVersion;
34
+ for (const from of [() => createRequire(join(process.cwd(), "package.json")), () => require_, () => { const root = execSync("npm root -g").toString().trim(); return (id) => require_(join(root, id)); }]) {
35
+ try { const r = from(); pw = r("playwright"); try { pwVersion = r("playwright/package.json").version; } catch {} break; } catch {}
21
36
  }
22
37
  if (!pw) { console.error("playwright is not installed: npm i -D playwright && npx playwright install chromium"); process.exit(2); }
23
38
  const { chromium, devices } = pw;
@@ -26,6 +41,13 @@ const here = dirname(fileURLToPath(import.meta.url));
26
41
  const arg = (k, d) => { const i = process.argv.indexOf(`--${k}`); return i > 0 ? process.argv[i + 1] : d; };
27
42
  let CFG = {}; try { CFG = JSON.parse(readFileSync(join(process.cwd(), "shipvo.json"), "utf8")); } catch {}
28
43
  const PORT = Number(arg("port", 4545)), DIR = arg("dir", "/tmp/pn-ui"), DIST = arg("dist", join(process.cwd(), "sim-dist"));
44
+ // phone.json: only a missing default ./phone.json is ignored. A bad --phone path or a bad file fails sessions with bad_profile.
45
+ let PROFILE = null, PROFILE_ERR = null;
46
+ { const given = arg("phone", null);
47
+ try { PROFILE = readFileSync(given ?? join(process.cwd(), "phone.json"), "utf8"); }
48
+ catch (e) { if (given || e?.code !== "ENOENT") PROFILE_ERR = `cannot read ${given ?? "phone.json"}: ${e?.code ?? e?.message}`; } }
49
+ // Simulated OTA source: a control plane stub that serves <url>/pointer.json (signed) and the bundle. The key is the app's public key.
50
+ const UPDATE = { url: arg("update-url", process.env.PN_UPDATE_URL || ""), key: arg("update-key", process.env.PN_UPDATE_KEY || "") };
29
51
  const DEFAULT_APP = arg("app", CFG.appId || "tide"), APP_NAME = CFG.name || "Tide & Tackle";
30
52
  // Published releases: open_app("tide@r13") loads the web artifact that `shipvo publish` stored next to the phone bundle.
31
53
  const CONTROL = (process.env.PN_CONTROL || process.env.CONTENT_FROM || CFG.controlPlane || "https://api.shipvo.dev").replace(/\/$/, "");
@@ -46,26 +68,79 @@ const MIME = { ".html": "text/html", ".js": "text/javascript", ".json": "applica
46
68
  const LAUNCH_TIMEOUT_MS = 60_000;
47
69
  let launchBrowser;
48
70
  const browserReady = new Promise((resolve, reject) => { launchBrowser = () => {
71
+ // Resolve first (it may download a browser, which can take longer than the launch timeout), then time the launch only.
72
+ let opts;
73
+ try { opts = resolveBrowser({ pw, executablePath: arg("chromium", undefined) }); } catch (e) { reject(e); return; }
49
74
  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));
75
+ chromium.launch(opts).then(resolve, reject).finally(() => clearTimeout(timer));
52
76
  }; });
53
- browserReady.catch((e) => { console.error(`sim: could not launch Chromium: ${e.message.split("\n")[0]}`); process.exit(1); });
77
+ browserReady.catch((e) => { console.error(`sim: ${launchErrorMessage(e.message, pwVersion)}`); process.exit(1); });
54
78
  const sessions = new Map();
55
79
 
56
80
  const phone = { ...devices["Pixel 5"], viewport: { width: 393, height: 851 } };
57
81
 
82
+ // The phone's Browser app. Chromium starts on the first open. Pages that link to the app's own scheme hand the link to the app.
83
+ // The instance is shared through s.browserP, so two opens at once make one Chromium context.
84
+ function phoneBrowser(s) {
85
+ s.browserP ??= (async () => {
86
+ s.peers = s.peers ?? new Set();
87
+ const b = new PhoneBrowser(await browserReady, { allowPrivate: false, allowHosts: [], device: phone, onDeepLink: (u) => {
88
+ s.page.evaluate((x) => globalThis.__pnDevice?.openUrl(x), u).catch(() => {});
89
+ for (const peer of s.peers) peer.send(JSON.stringify({ type: "app", url: u })); // the phone goes back to the app
90
+ } });
91
+ b.onChange = () => pushState(s);
92
+ s.browser = b;
93
+ return b;
94
+ })();
95
+ s.browserP.catch(() => { s.browserP = undefined; });
96
+ return s.browserP;
97
+ }
98
+ // One screencast feeds every viewer of the Browser app.
99
+ function ensureCast(s) {
100
+ const b = s.browser; if (!b?.page || b.screencast || !s.peers?.size) return;
101
+ b.startScreencast((f) => { const msg = JSON.stringify({ type: "frame", ...f }); for (const p of s.peers) p.send(msg); }).catch(() => {});
102
+ pushState(s);
103
+ }
104
+ async function pushState(s) {
105
+ const b = s.browser; if (!b || !s.peers?.size) return;
106
+ const tabs = await b.tabs().catch(() => []);
107
+ const msg = JSON.stringify({ type: "state", tabs, url: b.page?.url() ?? "" });
108
+ for (const peer of s.peers) peer.send(msg);
109
+ }
110
+ /** Opens a web page in the Browser app. Resolves { ok: true }, or { ok: false, code, message } when the page is refused. Viewers get an `error` message too. */
111
+ async function openInBrowserApp(id, url) {
112
+ const s = sessions.get(id); if (!s) return { ok: false, code: "no_session", message: "no session" };
113
+ const b = await phoneBrowser(s);
114
+ for (const peer of s.peers ?? []) peer.send(JSON.stringify({ type: "browser", url })); // the phone shows the Browser app
115
+ try { await b.open(url); ensureCast(s); return { ok: true }; } catch (e) {
116
+ const r = { ok: false, code: e.code ?? "error", message: String(e.message).split("\n")[0] };
117
+ for (const peer of s.peers ?? []) peer.send(JSON.stringify({ type: "error", code: r.code, message: r.message }));
118
+ return r;
119
+ }
120
+ }
121
+
58
122
  async function newSession(id, opts = {}) {
59
- if (sessions.has(id)) await sessions.get(id).ctx.close();
123
+ if (sessions.has(id)) { const old = sessions.get(id); for (const p of old.peers ?? []) p.close(); await (await old.browserP?.catch(() => undefined))?.close(); await old.ctx.close(); }
60
124
  const target = await resolveApp(opts.app);
61
125
  writeFileSync(join(DIR, `${id}.json`), JSON.stringify({ orders: [] })); writeFileSync(join(DIR, `${id}.json.log`), "");
62
126
  const browser = await browserReady;
63
- const ctx = await browser.newContext({ ...phone, colorScheme: opts.dark ? "dark" : "light" });
127
+ // Optional width, height (output pixels) and scale (device pixel ratio) make a session render at a store screenshot size.
128
+ const size = Number.isInteger(opts.width) && Number.isInteger(opts.height) && opts.width > 0 && opts.height > 0 ? { viewport: { width: Math.round(opts.width / (opts.scale || 1)), height: Math.round(opts.height / (opts.scale || 1)) }, deviceScaleFactor: opts.scale || 1 } : {};
129
+ const ctx = await browser.newContext({ ...phone, ...size, colorScheme: opts.dark ? "dark" : "light" });
130
+ // The app's Linking.openURL for a web page lands here (see @shipvo/device): it opens in the Browser app, not in the app.
131
+ await ctx.exposeFunction("__pnOpenBrowser", (url) => openInBrowserApp(id, String(url)));
64
132
  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 });
65
133
  const page = await ctx.newPage();
134
+ const errors = [];
135
+ // phone.json: the apps and settings this phone starts with. The same profile gives the same phone every run.
136
+ let apps; try { if (PROFILE_ERR && opts.phone === undefined) throw new AppsError("bad_profile", PROFILE_ERR); apps = loadProfile(opts.phone ?? PROFILE ?? {}); } catch (e) { await ctx.close(); throw e instanceof AppsError ? new SimError(e.code, e.message) : e; }
137
+ const pageErrors = { n: 0 };
138
+ page.on("pageerror", (e) => { pageErrors.n++; errors.push(String(e?.message ?? e).split("\n")[0]); });
66
139
  await page.goto(target.url, { waitUntil: "networkidle" });
67
140
  await page.waitForTimeout(300);
68
- sessions.set(id, { ctx, page, app: target.app, release: target.release, startedAt: new Date().toISOString() });
141
+ const patch = profilePatch(apps);
142
+ if (Object.keys(patch).length) { await page.evaluate((p) => globalThis.__pnDevice?.set?.(p), patch); await syncMedia(page); }
143
+ sessions.set(id, { pageErrors, update: createUpdate({ release: target.release, url: target.url }), updateSrc: { url: opts.update_url ?? UPDATE.url, key: opts.update_key ?? UPDATE.key }, apps, ctx, page, errors, app: target.app, release: target.release, startedAt: new Date().toISOString(), life: createLifecycle(), shade: { open: "none" }, nav: createNav() });
69
144
  }
70
145
 
71
146
  // Refs are only meaningful for the document that issued them: a full page load restarts Playwright's counter, so an
@@ -77,48 +152,73 @@ async function epochOf(page) {
77
152
  if (!d) DOCS.set(page, { id, epoch: 0 }); else if (d.id !== id) { d.id = id; d.epoch++; }
78
153
  return DOCS.get(page).epoch;
79
154
  }
155
+ // The update banner is a status element in the page, so it is in the accessibility tree. A reload drops it, so boot puts it back.
156
+ async function showBanner(s) {
157
+ const text = bannerText(s.update);
158
+ await s.page.evaluate((t) => { document.querySelector("[data-pn-update-banner]")?.remove(); if (!t) return;
159
+ const d = document.createElement("div"); d.setAttribute("data-pn-update-banner", ""); d.setAttribute("role", "status"); d.setAttribute("aria-label", t); d.textContent = t;
160
+ d.style.cssText = "position:fixed;left:8px;right:8px;top:8px;z-index:2147483647;padding:8px 12px;border-radius:12px;background:#1d2733;color:#fff;font:600 13px system-ui,sans-serif;pointer-events:none";
161
+ document.body.appendChild(d); }, text).catch(() => {});
162
+ }
163
+ // One boot of the app. `url` loads that bundle, else the page reloads (a cold start). A page error while loading is a failed boot: three in a row
164
+ // on a new release send the phone back to the release it had.
165
+ async function boot(s, url) {
166
+ const before = s.pageErrors.n;
167
+ if (url) await s.page.goto(url, { waitUntil: "networkidle" }); else await s.page.reload({ waitUntil: "networkidle" });
168
+ await s.page.waitForTimeout(300);
169
+ const r = bootDone(s.update, s.pageErrors.n > before);
170
+ if (r.revert) { await s.page.goto(r.url, { waitUntil: "networkidle" }); await s.page.waitForTimeout(300); }
171
+ await showBanner(s);
172
+ }
173
+ // Fetch layer. Mock rules win; otherwise fetch/XHR run under the session's network profile (default online, untouched).
174
+ async function ensureNetworkRoute(s, page) {
175
+ if (s.mockRoute) return;
176
+ s.mockRoute = true;
177
+ await page.route("**/*", async (route) => {
178
+ const req = route.request();
179
+ const type = req.resourceType();
180
+ if (type !== "fetch" && type !== "xhr") return route.fallback();
181
+ const rule = (s.mocks ?? []).find((m) => (!m.method || m.method === req.method()) && m.match(req.url()));
182
+ if (rule) {
183
+ if (rule.delayMs) await new Promise((r) => setTimeout(r, rule.delayMs));
184
+ // The app page may come from another origin, so the stub must pass the browser's CORS check.
185
+ return route.fulfill({ status: rule.status, body: rule.body, headers: { "access-control-allow-origin": "*", ...rule.headers } }).catch(() => {});
186
+ }
187
+ if (!s.profiled) return route.fallback();
188
+ try {
189
+ const { res } = await s.profiled(req.url(), { method: req.method(), body: req.postDataBuffer() ?? undefined, route });
190
+ await route.fulfill({ response: res }).catch(() => {});
191
+ } catch { await route.abort("internetdisconnected").catch(() => {}); }
192
+ });
193
+ }
194
+
195
+ // Set the session's network profile. Throws unknown_profile for a bad name. Logged in device_log as simulated.
196
+ async function setNetworkProfile(s, page, name) {
197
+ let profile; try { profile = getProfile(name); } catch (e) { throw new SimError(e.code, e.message); }
198
+ const from = s.netProfile ?? "online";
199
+ s.netProfile = name;
200
+ s.profiled = name === "online" ? null : applyProfile(async (_url, init) => {
201
+ // Playwright's APIResponse has headers() as a method. A chunked reply has no content-length, so use the body size.
202
+ const res = await init.route.fetch(); const headers = res.headers(); const size = String((await res.body()).length);
203
+ return { res, headers: { get: (n) => { const k = String(n).toLowerCase(); return headers[k] ?? (k === "content-length" ? size : null); } } };
204
+ }, profile, { clock: { sleep: (ms) => new Promise((r) => setTimeout(r, ms)) } });
205
+ if (s.profiled) await ensureNetworkRoute(s, page);
206
+ (s.netLog ??= []).push({ at: Date.now(), name: "network_profile", profile: name, from, source: "simulated" });
207
+ }
208
+
80
209
  class SimError extends Error { constructor(code, message) { super(message); this.code = code; } }
81
210
 
82
211
  async function screen(page) {
83
212
  await page.waitForTimeout(150);
84
213
  const epoch = await epochOf(page);
85
- // Phones only expose the items of a horizontal list that are on screen; hide the rest, as a phone would,
86
- // so an agent learns to swipe the rail ("scroll right <ref>") exactly as it must on a device.
87
- await page.evaluate(() => {
88
- for (const el of document.querySelectorAll("[data-pn-offscreen]")) { el.removeAttribute("aria-hidden"); el.removeAttribute("data-pn-offscreen"); }
89
- // A modal sheet (accessibilityViewIsModal / aria-modal) hides everything behind it from assistive tech on phones.
90
- const modal = [...document.querySelectorAll('[aria-modal="true"]')].pop();
91
- if (modal) for (let node = modal; node && node !== document.body; node = node.parentElement)
92
- for (const sib of node.parentElement?.children ?? []) if (sib !== node && !sib.hasAttribute("aria-hidden")) { sib.setAttribute("aria-hidden", "true"); sib.setAttribute("data-pn-offscreen", "1"); }
93
- for (const sc of document.querySelectorAll("div")) {
94
- const cs = getComputedStyle(sc);
95
- if (!/(auto|scroll)/.test(cs.overflowX) || sc.scrollWidth <= sc.clientWidth + 1) continue;
96
- const box = sc.getBoundingClientRect();
97
- const row = sc.firstElementChild; if (!row) continue;
98
- for (const item of row.children) {
99
- const r = item.getBoundingClientRect();
100
- if (r.right <= box.left + 1 || r.left >= box.right - 1) { item.setAttribute("aria-hidden", "true"); item.setAttribute("data-pn-offscreen", "1"); }
101
- }
102
- }
103
- });
214
+ // Hide what a phone would not expose, so an agent learns to swipe a rail exactly as it must on a device.
215
+ await page.evaluate(`(${clearScreenRules})(document); (${applyScreenRules})(document)`);
104
216
  // What is hidden from assistive tech must not appear at all: this Playwright keeps aria-hidden nodes but strips
105
217
  // their names. `inert` removes them from the accessibility tree; it is set only for the snapshot and removed after.
106
218
  await page.evaluate(() => { for (const el of document.querySelectorAll('[aria-hidden="true"]')) { el.setAttribute("data-pn-prev-display", el.style.display || ""); el.style.setProperty("display", "none", "important"); } });
107
219
  const tree = await page.locator("body").ariaSnapshot({ mode: "ai" });
108
220
  await page.evaluate(() => { for (const el of document.querySelectorAll("[data-pn-prev-display]")) { const prev = el.getAttribute("data-pn-prev-display"); if (prev) el.style.display = prev; else el.style.removeProperty("display"); el.removeAttribute("data-pn-prev-display"); } });
109
- const lines = tree.split("\n").filter((l) => !/^\s*- generic \[ref=e\d+\]:?\s*$/.test(l));
110
- // A screen reader announces a control's label, not the words inside it (Android and iOS group a pressable's
111
- // children into one element). Drop the inner text lines of labelled controls, as the phones do.
112
- const out = []; let hideBelow = -1;
113
- for (const l of lines) {
114
- const indent = l.search(/\S/);
115
- if (hideBelow >= 0 && indent > hideBelow) continue;
116
- hideBelow = -1;
117
- if (/\[aria-hidden\]/.test(l)) { hideBelow = indent; continue; } // hidden from assistive tech: not on screen
118
- out.push(l);
119
- if (/^\s*- (button|radio|link|checkbox|switch|tab) "[^"]+"/.test(l) && /:\s*$/.test(l)) hideBelow = indent;
120
- }
121
- const text = out.join("\n");
221
+ const text = filterTree(tree);
122
222
  return epoch ? text.replace(/\[ref=(e\d+)\]/g, `[ref=p${epoch}$1]`) : text;
123
223
  }
124
224
 
@@ -157,9 +257,86 @@ async function act(id, body) {
157
257
  });
158
258
  if (covered) throw fail("covered", String(body.ref));
159
259
  };
260
+ // A locked phone takes no input: say so with a typed error before anything reaches the app.
261
+ if (["tap", "tap_xy", "type_keys", "type", "scroll", "back", "open_url", "open_notification", "install_app", "uninstall_app", "switch_app", ...SHADE_ACTIONS, ...SETTINGS_ACTIONS].includes(body.action)) { try { assertUnlocked(s.life); } catch (e) { throw new SimError(e.code, e.message); } }
262
+ // Home screen or app switcher showing: the app is in the background and takes no input.
263
+ if (["tap", "tap_xy", "type_keys", "type", "scroll", "back", "open_url", "open_notification", "open_notifications", "open_control_center", "toggle_tile", "dismiss_notification"].includes(body.action)) { try { assertForeground(s.nav); } catch (e) { throw new SimError(e.code, e.message); } }
264
+ // An open panel covers the app on a phone, so input to the app is refused until it is closed.
265
+ if (["tap", "tap_xy", "type_keys", "type", "scroll"].includes(body.action) && s.shade.open !== "none") throw new SimError("panel_open", `a panel (${s.shade.open}) is open and covers the app, so nothing was sent to it. Close it first (close_panels).`);
160
266
  switch (body.action) {
267
+ // Home, recents, resume and kill. The app hears them as AppState changes (document.visibilityState). Kill is a cold start.
268
+ case "press_home": case "open_recents": case "resume_app": case "kill_app": {
269
+ let ev;
270
+ try { ev = body.action === "press_home" ? pressHome(s.nav) : body.action === "open_recents" ? openRecents(s.nav) : body.action === "resume_app" ? resume(s.nav) : (killApp(s.nav), null); }
271
+ catch (e) { if (e instanceof NavError) throw new SimError(e.code, e.message); throw e; }
272
+ if (ev) await page.evaluate(setVisibilityInPage, ev);
273
+ if (body.action === "kill_app") { s.life.reloads++; await boot(s); await page.evaluate(setVisibilityInPage, "background"); }
274
+ const where = s.nav.where;
275
+ return { where, appState: where === "app" ? "active" : "background", reloads: s.life.reloads, screen: where === "app" ? await screen(page) : where === "home" ? `Home screen\n- button "${APP_NAME}" (the app icon: resume_app opens it)\n${describeApps(s.apps)}` : `App switcher\n- card "${APP_NAME}" (resume_app opens it, kill_app closes it)` };
276
+ }
277
+ // Installed apps. Errors are typed: unknown_app, already_installed, not_installed. Logged in device_log as simulated.
278
+ case "install_app": case "uninstall_app": case "switch_app": {
279
+ const id = String(body.id ?? "");
280
+ try {
281
+ if (body.action === "install_app") installApp(s.apps, id, Array.isArray(body.catalog) ? body.catalog : s.apps.store);
282
+ else if (body.action === "uninstall_app") uninstallApp(s.apps, id, s.nav);
283
+ else {
284
+ const url = s.apps.installed_apps.find((a) => a.id === id)?.url ?? "";
285
+ // fixture: pages are stand-ins (no navigation). app://<id>[@rN] loads a published release like open_app. Anything else
286
+ // must pass the same private-host guard as the Browser app, so a profile cannot point Chromium at the local network.
287
+ let dest = null;
288
+ const am = /^app:\/\/([a-z0-9-]+(?:@(?:r?\d+|latest))?)$/.exec(url);
289
+ if (am) dest = (await resolveApp(am[1])).url;
290
+ else if (/^[a-z][a-z0-9+.-]*:/i.test(url) && !/^fixture:/i.test(url)) {
291
+ try { dest = (await checkUrl(url, { allowPrivate: false, allowHosts: [] })).href; }
292
+ catch (e) { if (e instanceof BrowserError) throw new SimError(e.code, e.message); throw e; }
293
+ }
294
+ switchApp(s.apps, id, s.nav);
295
+ if (dest && page.url() !== dest) { await page.goto(dest, { waitUntil: "networkidle" }); await page.waitForTimeout(300); }
296
+ await page.evaluate(setVisibilityInPage, "active");
297
+ }
298
+ } catch (e) { if (e instanceof AppsError) throw new SimError(e.code, e.message); throw e; }
299
+ if (body.action === "uninstall_app" && s.nav.where === "home") await page.evaluate(setVisibilityInPage, "background");
300
+ const where = s.nav.where;
301
+ return { where, current_app: s.apps.current_app, installed_apps: s.apps.installed_apps, screen: where === "app" ? await screen(page) : `Home screen\n${describeApps(s.apps)}` };
302
+ }
303
+ // Simulated lock, unlock and restart. Restart reloads the page (a cold start of the app); the browser context keeps storage.
304
+ case "lock": case "unlock": {
305
+ if (body.action === "lock") s.shade.open = "none";
306
+ try { (body.action === "lock" ? lock : unlock)(s.life); } catch (e) { if (e instanceof LifecycleError) throw new SimError(e.code, e.message); throw e; }
307
+ return { locked: s.life.locked, screen: s.life.locked ? "Lock screen" : await screen(page) };
308
+ }
309
+ // Simulated OTA: check (signed pointer, hash), apply (restart on the new bundle), state. Errors: no_update, not_ready. Every change is in device_log.
310
+ case "get_update_state": case "check_for_update": case "apply_update": {
311
+ try {
312
+ if (body.action === "check_for_update") { await checkForUpdate(s.update, { url: s.updateSrc.url, publicKey: s.updateSrc.key }); await showBanner(s); }
313
+ else if (body.action === "apply_update") {
314
+ applyUpdate(s.update, (n) => `http://localhost:${PORT}/u/${body.session}/r${n}/index.html`);
315
+ s.life.reloads++; s.shade.open = "none"; s.nav.where = "app"; s.life.locked = false;
316
+ await boot(s, s.update.running.url);
317
+ }
318
+ } catch (e) { if (e instanceof UpdateError) throw new SimError(e.code, e.message); throw e; }
319
+ return { update: updateView(s.update), banner: bannerText(s.update), reloads: s.life.reloads, screen: await screen(page) };
320
+ }
321
+ case "restart": {
322
+ restart(s.life); s.shade.open = "none"; s.nav.where = "app";
323
+ await boot(s);
324
+ return { reloads: s.life.reloads, restarts: s.life.restarts, screen: await screen(page) };
325
+ }
161
326
  case "screen": break;
327
+ // Coordinate input, for the "screenshots and coordinates only" side of scripts/agent-efficiency. Not an MCP tool: agents using
328
+ // the labelled screen tap by ref. x and y are CSS pixels from the top left of the screenshot.
329
+ case "tap_xy": { const x = Number(body.x), y = Number(body.y); if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("tap_xy needs numbers x and y"); await page.mouse.click(x, y); break; }
330
+ case "type_keys": await page.keyboard.type(String(body.text ?? ""), { delay: 5 }); if (body.enter) await page.keyboard.press("Enter"); break;
162
331
  case "tap": { const t = await resolve(body.ref); await ready(t); await t.click({ timeout: 3000 }); break; }
332
+ // Where an element is: centre and size as 0..1 of the viewport. Used by the recorder to draw taps. Does not act.
333
+ case "locate": {
334
+ let box = null; const vp = page.viewportSize();
335
+ try { box = await (await resolve(body.ref)).boundingBox({ timeout: 1000 }); } catch {}
336
+ if (!box || !vp) throw new SimError("not_found", `no element ${body.ref} on this screen.`);
337
+ const c = (v) => Math.min(1, Math.max(0, v));
338
+ return { locate: { x: c((box.x + box.width / 2) / vp.width), y: c((box.y + box.height / 2) / vp.height), w: c(box.width / vp.width), h: c(box.height / vp.height) } };
339
+ }
163
340
  case "type": {
164
341
  // A labelled text field may resolve to its wrapper; type into the real input inside it, as a phone focuses the field.
165
342
  let t = await resolve(body.ref);
@@ -183,10 +360,16 @@ async function act(id, body) {
183
360
  break;
184
361
  }
185
362
  // Simulated device: what the phone "has" and what happens to it. See @shipvo/device.
186
- case "set_device": await page.evaluate((d) => globalThis.__pnDevice?.set(d), body.device ?? {}); break;
363
+ case "set_device": await page.evaluate((d) => globalThis.__pnDevice?.set(d), body.device ?? {}); await syncMedia(page); break;
364
+ case "open_notifications": case "open_control_center": case "close_panels": case "toggle_tile": case "dismiss_notification": {
365
+ try { return { ...(await shadeAction(page, s.shade, body)) }; } catch (e) { if (e instanceof ShadeError) throw new SimError(e.code, e.message); throw e; }
366
+ }
367
+ case "get_settings": case "set_setting": {
368
+ try { return { ...(await settingsAction(page, body)) }; } catch (e) { if (e instanceof SettingsError) throw new SimError(e.code, e.message); throw e; }
369
+ }
187
370
  case "push_notification": return { ...(await pushNotification(page, body)), screen: await screen(page) };
188
371
  case "notifications": return { notifications: await page.evaluate(() => globalThis.__pnDevice?.notifications() ?? []), screen: await screen(page) };
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; }
372
+ case "open_notification": { s.shade.open = "none"; 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
373
  case "open_url": {
191
374
  if (typeof body.url !== "string" || !body.url) throw new SimError("bad_url", "open_url needs a url, such as myapp://item/3");
192
375
  const handled = await page.evaluate((u) => globalThis.__pnDevice?.openUrl(u), body.url);
@@ -197,21 +380,31 @@ async function act(id, body) {
197
380
  case "set_network_mocks": {
198
381
  let rules; try { rules = parseMockRules(body.rules ?? []); } catch (e) { throw new SimError(e.code ?? "mock_invalid", e.message); }
199
382
  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
- }
383
+ await ensureNetworkRoute(s, page);
212
384
  break;
213
385
  }
214
- case "device_log": return { log: await page.evaluate(() => globalThis.__pnDevice?.log() ?? []), capabilities: await page.evaluate(() => globalThis.__pnDevice?.capabilities() ?? null), screen: await screen(page) };
386
+ case "set_network_profile": { await setNetworkProfile(s, page, body.profile); break; }
387
+ // The phone's Chromium (isolated from the app page). Page text is untrusted: results carry source "web".
388
+ case "browser_open": case "browser_snapshot": case "browser_click": case "browser_type": case "browser_back": case "browser_screenshot": case "browser_tabs": case "browser_close_tab": case "browser_scroll": case "browser_wait_for": {
389
+ if (body.action === "browser_open" && body.url) { await phoneBrowser(s); if (body.allow_private !== undefined) s.browser.opts.allowPrivate = body.allow_private === true; if (Array.isArray(body.allow_hosts)) s.browser.opts.allowHosts = body.allow_hosts.map(String); }
390
+ if (!s.browser) { if (body.action === "browser_open") throw new SimError("bad_url", "browser_open needs a url"); throw new SimError("no_page", "no page is open: call browser_open first"); }
391
+ const b = s.browser;
392
+ try {
393
+ switch (body.action) {
394
+ case "browser_open": { const r = await b.open(String(body.url ?? "")); ensureCast(s); return { ...r }; }
395
+ case "browser_snapshot": return { ...(await b.snapshot()) };
396
+ case "browser_click": return { ...(await b.click(body.ref)) };
397
+ case "browser_type": return { ...(await b.type(body.ref, body.text, body.enter)) };
398
+ case "browser_back": return { ...(await b.back()) };
399
+ case "browser_tabs": return { source: "web", untrusted: true, tabs: await b.tabs() };
400
+ case "browser_close_tab": return { source: "web", untrusted: true, tabs: await b.closeTab(Number(body.index)) };
401
+ case "browser_scroll": return { ...(await b.scroll(Number.isFinite(body.dy) ? body.dy : 400, Number.isFinite(body.dx) ? body.dx : 0)) };
402
+ case "browser_wait_for": return { ...(await b.waitFor({ text: body.text, gone: body.gone === true, timeoutMs: body.timeout_ms })) };
403
+ default: { const p = join(DIR, `${id}-web-${Date.now()}.png`); writeFileSync(p, await b.screenshot()); return { path: p, source: "web", untrusted: true }; }
404
+ }
405
+ } catch (e) { throw e.code ? new SimError(e.code, e.message) : e; }
406
+ }
407
+ case "device_log": return { log: [...(await page.evaluate(() => globalThis.__pnDevice?.log() ?? [])), ...s.life.log, ...s.apps.log, ...s.update.log, ...(s.netLog ?? []), ...(s.browser?.navigations ?? [])], capabilities: await page.evaluate(() => globalThis.__pnDevice?.capabilities() ?? null), screen: await screen(page) };
215
408
  case "diag": {
216
409
  const r = await page.evaluate(async () => {
217
410
  const cfg = globalThis.__PN_SIM__ ?? {}; const out = { baseUrl: cfg.baseUrl, origin: location.origin };
@@ -221,10 +414,30 @@ async function act(id, body) {
221
414
  });
222
415
  return { diag: r, screen: await screen(page) };
223
416
  }
224
- case "device_reset": await page.evaluate((d) => globalThis.__pnDevice?.reset(d), body.device ?? {}); 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; }
226
- case "screenshot": { const p = join(DIR, `${id}-${Date.now()}.png`); await page.screenshot({ path: p }); return { path: p, screen: await screen(page) }; }
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`);
417
+ case "device_reset": await page.evaluate((d) => globalThis.__pnDevice?.reset(d), body.device ?? {}); await syncMedia(page); s.shade.open = "none"; break;
418
+ // Uncaught page errors since the last call to this action. Reading them clears the list.
419
+ case "errors": return { errors: s.errors.splice(0), screen: await screen(page) };
420
+ // Rendered size in CSS px of each ref on the current screen: { sizes: { ref: [width, height] } }. Refs that are gone are left out.
421
+ case "sizes": {
422
+ const text = await screen(page); const sizes = {};
423
+ for (const m of text.matchAll(/\[ref=(\w+)\]/g)) {
424
+ const raw = m[1].replace(/^p\d+(?=e\d+$)/, "");
425
+ const box = await page.locator(`aria-ref=${raw}`).first().boundingBox().catch(() => null);
426
+ if (box) sizes[m[1]] = [Math.round(box.width * 10) / 10, Math.round(box.height * 10) / 10];
427
+ }
428
+ return { sizes, screen: text };
429
+ }
430
+ case "back": { if (s.shade.open !== "none") { s.shade.open = "none"; break; } 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; }
431
+ case "screenshot": {
432
+ // format "jpeg" has no alpha channel, which the stores require. inline: true also returns the bytes as base64 for clients on another machine.
433
+ const jpeg = body.format === "jpeg"; const p = join(DIR, `${id}-${Date.now()}.${jpeg ? "jpg" : "png"}`);
434
+ // Home screen or switcher showing: draw it over the app for the shot, then take it away. The app stays loaded underneath.
435
+ const away = s.nav.where !== "app";
436
+ if (away) await page.evaluate((h) => { const d = document.createElement("div"); d.id = "__pn_away"; d.innerHTML = h; document.body.appendChild(d); }, awayScreenHtml(s.nav.where, APP_NAME));
437
+ let buf; try { buf = await page.screenshot(jpeg ? { path: p, type: "jpeg", quality: 90 } : { path: p }); } finally { if (away) await page.evaluate(() => document.getElementById("__pn_away")?.remove()); }
438
+ return { path: p, ...(body.inline ? { data: buf.toString("base64") } : {}), screen: await screen(page) };
439
+ }
440
+ default: throw new Error(`unknown action '${body.action}'. Actions: screen, get_update_state, check_for_update, apply_update, install_app, uninstall_app, switch_app, lock, unlock, restart, press_home, open_recents, resume_app, kill_app, open_notifications, open_control_center, close_panels, toggle_tile, dismiss_notification, get_settings, set_setting, tap, locate, type, scroll, back, screenshot, set_device, push_notification, notifications, open_notification, open_url, errors, sizes, device_log, device_reset, set_network_mocks, set_network_profile, browser_open, browser_snapshot, browser_click, browser_type, browser_back, browser_screenshot, browser_tabs, browser_close_tab, browser_scroll, browser_wait_for`);
228
441
  }
229
442
  return { ...(["tap", "scroll", "back"].includes(body.action) ? { delivery: "delivered" } : {}), screen: await screen(page) };
230
443
  }
@@ -250,6 +463,9 @@ const server = http.createServer(async (req, res) => {
250
463
  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); }
251
464
  return send(200, hit.body, hit.type);
252
465
  }
466
+ // a bundle this session downloaded and verified: the page that runs is the page that was checked
467
+ { const um = url.pathname.match(/^\/u\/([\w-]+)\/r(\d+)\/index\.html$/);
468
+ if (um) { const b = sessions.get(um[1])?.update.bundles.get(Number(um[2])); return b ? send(200, b, MIME[".html"]) : send(404, { error: "no_bundle" }); } }
253
469
  // a founder opening the printed link lands on the app
254
470
  if ((req.method === "GET" || req.method === "HEAD") && url.pathname === "/") { res.writeHead(302, { location: "/app/", ...CORS }); return res.end(); }
255
471
  if (url.pathname.startsWith("/app/")) {
@@ -274,12 +490,14 @@ const server = http.createServer(async (req, res) => {
274
490
  }
275
491
  return send(202, { ok: true }); // telemetry etc.
276
492
  }
493
+ // the Browser app, in a phone frame
494
+ if (req.method === "GET" && url.pathname === "/browser") return send(200, browserAppPage(url.searchParams.get("skin") === "ios" ? "ios" : "android", url.searchParams.get("session") ?? ""), MIME[".html"]);
277
495
  // agent API
278
496
  if (req.method !== "POST") return send(404, { error: "not found" });
279
497
  let raw = ""; for await (const ch of req) raw += ch; const body = raw ? JSON.parse(raw) : {};
280
498
  if (url.pathname === "/apps") return send(200, { ok: true, apps: [{ id: DEFAULT_APP, name: APP_NAME }] });
281
499
  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() })) });
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) }); }
500
+ if (url.pathname === "/session") { try { await newSession(body.session, body); } catch (e) { if (e instanceof SimError && e.code === "bad_profile") return send(200, { ok: false, code: e.code, error: e.message }); throw e; } 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) }); }
283
501
  if (url.pathname === "/act") {
284
502
  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 }); }
285
503
  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 }); }
@@ -287,6 +505,33 @@ const server = http.createServer(async (req, res) => {
287
505
  send(404, { error: "not found" });
288
506
  } catch (e) { send(500, { error: String(e) }); }
289
507
  });
508
+ // The Browser app's live view: frames out, taps, swipes and typing in. Same trust as the HTTP API (loopback), plus an Origin check
509
+ // so another web page open on this machine cannot drive the phone.
510
+ server.on("upgrade", (req, socket) => {
511
+ const url = new URL(req.url, "http://x");
512
+ const refuse = (line) => socket.end(`HTTP/1.1 ${line}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
513
+ if (url.pathname !== "/browser/ws") return refuse("404 Not Found");
514
+ let originHost = null; try { if (req.headers.origin) originHost = new URL(req.headers.origin).host; } catch { originHost = "invalid"; }
515
+ // A missing Origin is allowed (non-browser clients). The Host comparison does not stop DNS rebinding, since both
516
+ // values come from the requester; the HTTP API has the same trust level.
517
+ if (originHost && originHost !== req.headers.host) return refuse("403 Forbidden");
518
+ const s = sessions.get(url.searchParams.get("session") ?? ""); if (!s) return refuse("404 Not Found");
519
+ const peer = acceptWebSocket(req, socket, async (text) => {
520
+ let m; try { m = JSON.parse(text); } catch { return; }
521
+ const b = s.browser;
522
+ try {
523
+ if (m.type === "open") { await openInBrowserApp(url.searchParams.get("session"), String(m.url)); return; } // a refusal reaches every viewer as an `error` message
524
+ if (!b) return;
525
+ if (m.type === "back") await b.back(); else if (m.type === "forward") await b.forward(); else if (m.type === "reload") await b.reload();
526
+ else if (m.type === "tab") await b.selectTab(Number(m.index)); else if (m.type === "newtab") await b.newTab(); else if (m.type === "closetab") await b.closeTab(Number(m.index));
527
+ else await b.input(m);
528
+ } catch (e) { peer.send(JSON.stringify({ type: "error", code: e.code ?? "error", message: String(e.message).split("\n")[0] })); }
529
+ }, () => { s.peers?.delete(peer); if (!s.peers?.size) s.browser?.stopScreencast(); });
530
+ if (!peer) return;
531
+ s.peers = s.peers ?? new Set(); s.peers.add(peer);
532
+ peer.send(JSON.stringify({ type: "hello", browser: !!s.browser }));
533
+ ensureCast(s);
534
+ });
290
535
  // Agents and parity runs pause between actions; keep idle connections open well beyond Node's 5 s default.
291
536
  server.keepAliveTimeout = 300_000; server.headersTimeout = 310_000; server.requestTimeout = 0;
292
537
  server.listen(PORT, () => console.log(`Shipvo UI simulator on http://localhost:${PORT} (sessions in ${DIR})`));
@@ -0,0 +1,28 @@
1
+ // The server side of the Settings screen: the MCP actions get_settings and set_setting. Errors are SettingsError with a typed `code`.
2
+ import { SettingsError, settingPatch, readSettings, describeSettings } from "./settings.mjs";
3
+
4
+ const read = async (page) => {
5
+ const sys = await page.evaluate(() => globalThis.__pnDevice?.system?.() ?? null);
6
+ if (!sys) throw new SettingsError("no_device", "the app has no simulated device in this session");
7
+ return sys;
8
+ };
9
+
10
+ /** Dark mode and reduce motion also reach the app's CSS media queries (prefers-color-scheme, prefers-reduced-motion). */
11
+ export async function syncMedia(page) {
12
+ const sys = await page.evaluate(() => globalThis.__pnDevice?.system?.() ?? null);
13
+ if (sys) await page.emulateMedia({ colorScheme: sys.darkMode ? "dark" : "light", reducedMotion: sys.reduceMotion ? "reduce" : "no-preference" });
14
+ }
15
+
16
+ const view = (sys) => { const settings = readSettings(sys); return { settings, screen: describeSettings(settings) }; };
17
+
18
+ /** Runs get_settings and set_setting. Returns the result fields. */
19
+ export async function settingsAction(page, body) {
20
+ if (body.action === "get_settings") return view(await read(page));
21
+ if (typeof body.key !== "string" || !body.key) throw new SettingsError("unknown_setting", "set_setting needs a key, such as dark_mode or font_size");
22
+ const patch = settingPatch(body.key, body.value); // unknown key and bad value fail before anything changes
23
+ await read(page);
24
+ await page.evaluate((p) => globalThis.__pnDevice.set(p), patch);
25
+ await syncMedia(page);
26
+ return view(await read(page));
27
+ }
28
+ export const SETTINGS_ACTIONS = ["get_settings", "set_setting"];