@shipvo/sim 0.1.3 → 0.1.5
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/browser-resolver.mjs +52 -0
- package/chromium-hint.mjs +19 -0
- package/frame.d.mts +37 -0
- package/frame.mjs +184 -0
- package/lifecycle.mjs +50 -0
- package/mock-rules.mjs +28 -0
- package/package.json +14 -1
- package/push-notification.mjs +7 -0
- package/screen-rules.mjs +45 -0
- package/server.mjs +107 -48
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Picks the browser the simulator launches: system Chrome, system Edge, Playwright's cached Chromium,
|
|
2
|
+
// then a one-time `npx playwright install chromium`. Pure: fs and process access are injected.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
export const INSTALL_COMMAND = "npx playwright install chromium";
|
|
7
|
+
|
|
8
|
+
/** Where each Playwright channel puts its binary, per platform. */
|
|
9
|
+
export function systemBrowserPaths(channel, platform, env) {
|
|
10
|
+
if (platform === "win32") {
|
|
11
|
+
const roots = [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter(Boolean);
|
|
12
|
+
const rel = channel === "chrome" ? "\\Google\\Chrome\\Application\\chrome.exe" : "\\Microsoft\\Edge\\Application\\msedge.exe";
|
|
13
|
+
return roots.map((r) => r + rel);
|
|
14
|
+
}
|
|
15
|
+
if (platform === "darwin") {
|
|
16
|
+
return channel === "chrome"
|
|
17
|
+
? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]
|
|
18
|
+
: ["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"];
|
|
19
|
+
}
|
|
20
|
+
return channel === "chrome"
|
|
21
|
+
? ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/opt/google/chrome/chrome"]
|
|
22
|
+
: ["/usr/bin/microsoft-edge", "/usr/bin/microsoft-edge-stable", "/opt/microsoft/msedge/msedge"];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const defaultRun = (cmd, args) => spawnSync(cmd, args, { stdio: "inherit", shell: process.platform === "win32" });
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns the options to pass to `chromium.launch`.
|
|
29
|
+
* Order: system Chrome, system Edge, Playwright cache, one-time install.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveBrowser({ pw, executablePath, env = process.env, fileExists = existsSync, run = defaultRun, platform = process.platform, log = (m) => console.error(m) }) {
|
|
32
|
+
if (executablePath) return { executablePath };
|
|
33
|
+
for (const channel of ["chrome", "msedge"]) {
|
|
34
|
+
if (systemBrowserPaths(channel, platform, env).some((p) => fileExists(p))) return { channel };
|
|
35
|
+
}
|
|
36
|
+
let cached;
|
|
37
|
+
try { cached = pw.chromium.executablePath(); } catch { cached = undefined; }
|
|
38
|
+
if (cached && fileExists(cached)) return {};
|
|
39
|
+
if (env.SHIPVO_NO_AUTO_INSTALL === "1") {
|
|
40
|
+
throw new Error(`no browser found (Chrome, Edge or Playwright Chromium) and SHIPVO_NO_AUTO_INSTALL=1 is set. Run \`${INSTALL_COMMAND}\``);
|
|
41
|
+
}
|
|
42
|
+
log(`sim: no Chrome, Edge or Chromium found; running \`${INSTALL_COMMAND}\` once (this downloads a browser)...`);
|
|
43
|
+
const [cmd, ...args] = INSTALL_COMMAND.split(" ");
|
|
44
|
+
const res = run(cmd, args);
|
|
45
|
+
if (!res || res.status !== 0 || res.error) {
|
|
46
|
+
const why = res && res.error ? `: ${res.error.message}` : "";
|
|
47
|
+
throw new Error(`\`${INSTALL_COMMAND}\` failed${why}. Run it yourself and check the output`);
|
|
48
|
+
}
|
|
49
|
+
let after; try { after = pw.chromium.executablePath(); } catch { after = undefined; }
|
|
50
|
+
if (!after || !fileExists(after)) throw new Error(`\`${INSTALL_COMMAND}\` failed: it exited 0 but no browser was installed. Run it yourself and check the output`);
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The fix to print when Playwright cannot find the Chromium build it wants.
|
|
2
|
+
export const CHROMIUM_MISSING = /Executable doesn't exist/;
|
|
3
|
+
// Linux: the browser exists but shared libraries are missing.
|
|
4
|
+
export const LIBS_MISSING = /error while loading shared libraries|Host system is missing dependencies/i;
|
|
5
|
+
|
|
6
|
+
/** The one-line install command for the resolved playwright version, plus the Linux hint. */
|
|
7
|
+
export function chromiumInstallFix(version) {
|
|
8
|
+
const v = version ? ` (playwright ${version})` : "";
|
|
9
|
+
return `run \`npx playwright install chromium\` in the app folder${v}. On Linux, if Chromium then fails to start, run \`npx playwright install --with-deps chromium\` or install the system packages it lists.`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The message to print for a launch error. Returns the first line, plus the fix when the browser is not installed. */
|
|
13
|
+
export function launchErrorMessage(message, version) {
|
|
14
|
+
const first = String(message).split("\n")[0];
|
|
15
|
+
const base = `could not launch Chromium: ${first}`;
|
|
16
|
+
if (CHROMIUM_MISSING.test(message)) return `${base}\nFix: ${chromiumInstallFix(version)}`;
|
|
17
|
+
if (LIBS_MISSING.test(message)) return `${base}\nFix: system libraries are missing. Run \`npx playwright install-deps chromium\``;
|
|
18
|
+
return base;
|
|
19
|
+
}
|
package/frame.d.mts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type FrameSkin = "android" | "ios";
|
|
2
|
+
export type FrameStatus = {
|
|
3
|
+
/** Text shown on the left, for example "9:41". */
|
|
4
|
+
time?: string;
|
|
5
|
+
/** Bars filled, 0 to 4. Default 4. */
|
|
6
|
+
signal?: number;
|
|
7
|
+
/** Default true. */
|
|
8
|
+
wifi?: boolean;
|
|
9
|
+
/** Shows an airplane icon and hides signal and wifi. */
|
|
10
|
+
airplane?: boolean;
|
|
11
|
+
/** Level 0 to 100, or an object. Default 100. */
|
|
12
|
+
battery?: number | { level?: number; charging?: boolean };
|
|
13
|
+
};
|
|
14
|
+
export type FrameOptions = { skin?: FrameSkin; dark?: boolean; status?: FrameStatus; /** HTML for the screen area. */ screen?: string };
|
|
15
|
+
export type Frame = {
|
|
16
|
+
skin: FrameSkin;
|
|
17
|
+
/** Screen size in CSS pixels. */
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
/** Size of the whole body, bezel included. */
|
|
21
|
+
outerWidth: number;
|
|
22
|
+
outerHeight: number;
|
|
23
|
+
html: string;
|
|
24
|
+
css: string;
|
|
25
|
+
};
|
|
26
|
+
export const SKINS: Record<FrameSkin, { label: string; width: number; height: number; radius: number }>;
|
|
27
|
+
export function normalizeStatus(status?: FrameStatus): { time: string; signal: number; wifi: boolean; airplane: boolean; battery: { level: number; charging: boolean } };
|
|
28
|
+
export function renderFrame(options?: FrameOptions): Frame;
|
|
29
|
+
export type DeviceStatusInput = { online?: boolean; airplane?: boolean; battery?: number | { level?: number; charging?: boolean } };
|
|
30
|
+
/** Maps the simulated device state and a clock to a status object. */
|
|
31
|
+
export function statusFromDevice(device?: DeviceStatusInput, now?: Date): FrameStatus;
|
|
32
|
+
/** True when a CSS colour is dark, so the status text should be light. */
|
|
33
|
+
export function isDarkBackground(color: string): boolean;
|
|
34
|
+
/** The clock text and icons markup, for redrawing the status bar. */
|
|
35
|
+
export function statusParts(status?: FrameStatus): { time: string; icons: string };
|
|
36
|
+
/** Browser script that keeps the status bar live from the app's `pn:status` events. */
|
|
37
|
+
export function statusClientScript(iframeId?: string, skin?: FrameSkin): string;
|
package/frame.mjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// The phone body: bezel, cutout, status bar and a slot for the screen. Pure: no DOM, no timers, no dependencies.
|
|
2
|
+
// Our own artwork. It does not copy any vendor's icons, fonts or exact device outlines.
|
|
3
|
+
// Scaling to fit the window is done by the page with a CSS transform on the element this markup sits in.
|
|
4
|
+
|
|
5
|
+
import { lockScreenHtml, bootScreenHtml, offScreenHtml } from "./lifecycle.mjs";
|
|
6
|
+
|
|
7
|
+
export const SKINS = {
|
|
8
|
+
android: { label: "Android", width: 412, height: 915, radius: 36, bezel: 10, status: 36, bar: 24, pad: 22, indicator: 108, part: "punch-hole", bottom: "gesture-bar" },
|
|
9
|
+
ios: { label: "iPhone", width: 393, height: 852, radius: 55, bezel: 12, status: 54, bar: 34, pad: 36, indicator: 134, part: "dynamic-island", bottom: "home-indicator" },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const FOOT = 14; // extra bezel under the screen, holds the "Simulated" label
|
|
13
|
+
const clean = (s) => String(s).replace(/[<>&"'\\]/g, "").slice(0, 12);
|
|
14
|
+
const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
|
|
15
|
+
|
|
16
|
+
/** Fills in anything missing from a status object. Never throws on odd input. */
|
|
17
|
+
export function normalizeStatus(status = {}) {
|
|
18
|
+
const s = status && typeof status === "object" ? status : {};
|
|
19
|
+
const bat = typeof s.battery === "number" ? { level: s.battery } : (s.battery && typeof s.battery === "object" ? s.battery : {});
|
|
20
|
+
const num = (v, d) => (Number.isFinite(Number(v)) && v !== null && v !== "" ? Number(v) : d);
|
|
21
|
+
return {
|
|
22
|
+
time: s.time == null ? "9:41" : clean(s.time),
|
|
23
|
+
signal: Math.round(clamp(num(s.signal, 4), 0, 4)),
|
|
24
|
+
wifi: s.wifi !== false,
|
|
25
|
+
airplane: s.airplane === true,
|
|
26
|
+
battery: { level: Math.round(clamp(num(bat.level, 100), 0, 100)), charging: bat.charging === true },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Maps the simulated device ({ online, airplane, battery } from @shipvo/device) and a clock to a status object. Network off shows no signal and no wifi. */
|
|
31
|
+
export function statusFromDevice(device = {}, now = new Date()) {
|
|
32
|
+
const d = device && typeof device === "object" ? device : {};
|
|
33
|
+
const online = d.online !== false && d.airplane !== true;
|
|
34
|
+
const h = now.getHours() % 12 || 12, m = String(now.getMinutes()).padStart(2, "0");
|
|
35
|
+
return { time: `${h}:${m}`, signal: online ? 4 : 0, wifi: online, airplane: d.airplane === true, battery: d.battery };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** True when a CSS colour (#rgb, #rrggbb, rgb(), rgba()) is dark, so the status text should be light. Unknown or transparent colours count as light. */
|
|
39
|
+
export function isDarkBackground(color) {
|
|
40
|
+
const c = String(color ?? "").trim().toLowerCase();
|
|
41
|
+
let r, g, b, a = 1, m;
|
|
42
|
+
if ((m = /^#([0-9a-f]{3})$/.exec(c))) [r, g, b] = [...m[1]].map((x) => parseInt(x + x, 16));
|
|
43
|
+
else if ((m = /^#([0-9a-f]{6})/.exec(c))) [r, g, b] = [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16));
|
|
44
|
+
else if ((m = /^rgba?\(\s*([\d.]+)[ ,]+([\d.]+)[ ,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?/.exec(c))) { [r, g, b] = [+m[1], +m[2], +m[3]]; if (m[4] != null) a = m[4].endsWith("%") ? parseFloat(m[4]) / 100 : +m[4]; }
|
|
45
|
+
else return false;
|
|
46
|
+
return a >= 0.5 && 0.2126 * r + 0.7152 * g + 0.0722 * b < 128;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The parts of the status bar that change: the clock text and the icons markup. */
|
|
50
|
+
export function statusParts(status) {
|
|
51
|
+
const st = normalizeStatus(status);
|
|
52
|
+
return { time: st.time, icons: icons(st) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Browser script for the page (online is shown as 4 bars; the simulated device has no signal strength) that holds the frame. It watches the app's "pn:status" events (sent by @shipvo/device when network,
|
|
57
|
+
* airplane or battery change), redraws the status bar, ticks the clock each minute and flips the text colour from the app's top background.
|
|
58
|
+
* `iframeId` is the app iframe. It expects the markup from renderFrame and the functions below inlined by this script.
|
|
59
|
+
*/
|
|
60
|
+
export function statusClientScript(iframeId = "app", skin = "android") {
|
|
61
|
+
return `(function(){
|
|
62
|
+
var statusParts=${statusParts.toString()},normalizeStatus=${normalizeStatus.toString()},icons=${icons.toString()},battery=${battery.toString()},svg=${svg.toString()},isDarkBackground=${isDarkBackground.toString()},statusFromDevice=${statusFromDevice.toString()};
|
|
63
|
+
var clean=${clean.toString()},clamp=${clamp.toString()};
|
|
64
|
+
var dev={online:true,airplane:false,battery:{level:100,charging:false}};
|
|
65
|
+
function root(){return document.querySelector('[data-part="phone-body"]')}
|
|
66
|
+
function draw(){var r=root();if(!r)return;var p=statusParts(statusFromDevice(dev,new Date()));
|
|
67
|
+
r.querySelector('[data-part="time"]').textContent=p.time;var lt=r.querySelector('[data-part="lock-time"]');if(lt)lt.textContent=p.time;r.querySelector(".sf-icons").innerHTML=p.icons}
|
|
68
|
+
function topColor(w){try{var d=w.document,e=d.elementFromPoint(Math.floor(w.innerWidth/2),1);
|
|
69
|
+
while(e){var c=w.getComputedStyle(e).backgroundColor;if(c&&c!=="transparent"&&!/rgba\\(.*,\\s*0\\)$/.test(c))return c;e=e.parentElement}var b=d.body&&w.getComputedStyle(d.body).backgroundColor;if(b&&!/rgba\\(.*,\\s*0\\)$/.test(b)&&b!=="transparent")return b}catch(x){}return"#fff"}
|
|
70
|
+
function theme(w){var r=root();if(!r)return;var c=topColor(w),dark=isDarkBackground(c);
|
|
71
|
+
r.setAttribute("data-theme",dark?"dark":"light");r.style.setProperty("--sf-status-fg",dark?"#fff":"#111");r.style.setProperty("--sf-status-bg",c)}
|
|
72
|
+
function hook(){var f=document.getElementById(${JSON.stringify(iframeId)});var w=f&&f.contentWindow;if(!w)return;
|
|
73
|
+
if(w.__pnDevice&&w.__pnDevice.status)dev=w.__pnDevice.status();draw();theme(w);
|
|
74
|
+
if(!w.__shipvoStatusHooked){w.__shipvoStatusHooked=true;w.addEventListener("pn:status",function(e){dev=e.detail;draw();theme(w)})}}
|
|
75
|
+
var f=document.getElementById(${JSON.stringify(iframeId)});if(f){f.addEventListener("load",hook);hook()}
|
|
76
|
+
(function buttons(){var r=root();if(!r)return;var q=function(p){return r.querySelector('[data-part="'+p+'"]')},vol=50,hold,volT,bootT;
|
|
77
|
+
if(!q("power-button")||!q("lock-screen")||!q("power-menu"))return;
|
|
78
|
+
function show(el,on){if(el)el.hidden=!on}
|
|
79
|
+
function shown(p){var e=q(p);return !!e&&!e.hidden}
|
|
80
|
+
function lockNow(){show(q("power-menu"),false);show(q("lock-screen"),true);var u=q("unlock-button");if(u)u.focus()}
|
|
81
|
+
function unlockNow(){show(q("lock-screen"),false);var p=q("power-button");if(p)p.focus()}
|
|
82
|
+
function closeMenu(){if(shown("power-menu")){show(q("power-menu"),false);q("power-button").focus()}}
|
|
83
|
+
function powerOn(){show(q("off-screen"),false);show(q("boot-screen"),true);clearTimeout(bootT);bootT=setTimeout(function(){show(q("boot-screen"),false)},1200)}
|
|
84
|
+
function openMenu(){show(q("power-menu"),true);var b=q("power-menu").querySelector("button");if(b)b.focus()}
|
|
85
|
+
var sy=null,ls=q("lock-screen");
|
|
86
|
+
ls.addEventListener("pointerdown",function(e){sy=e.clientY});
|
|
87
|
+
ls.addEventListener("pointerup",function(e){if(sy!=null&&sy-e.clientY>60)unlockNow();sy=null});
|
|
88
|
+
ls.addEventListener("keydown",function(e){if(e.key==="ArrowUp"||e.key==="Enter"){e.preventDefault();unlockNow()}});
|
|
89
|
+
if(q("unlock-button"))q("unlock-button").addEventListener("click",unlockNow);
|
|
90
|
+
var pw=q("power-button");
|
|
91
|
+
function stopHold(){if(hold){clearTimeout(hold);hold=null}}
|
|
92
|
+
pw.addEventListener("pointerdown",function(){stopHold();hold=setTimeout(function(){hold=null;if(shown("off-screen"))return;openMenu();pw.setAttribute("data-held","1")},700)});
|
|
93
|
+
pw.addEventListener("pointerleave",stopHold);pw.addEventListener("pointercancel",stopHold);
|
|
94
|
+
pw.addEventListener("pointerup",stopHold);
|
|
95
|
+
pw.addEventListener("click",function(e){
|
|
96
|
+
if(pw.getAttribute("data-held")){pw.removeAttribute("data-held");return}
|
|
97
|
+
if(shown("off-screen")){powerOn();return}
|
|
98
|
+
if(e.detail===0&&e.shiftKey){shown("power-menu")?closeMenu():openMenu();return}
|
|
99
|
+
if(shown("power-menu")){closeMenu();return}
|
|
100
|
+
if(shown("lock-screen")){return}
|
|
101
|
+
lockNow()});
|
|
102
|
+
function bump(d){vol=Math.max(0,Math.min(100,vol+d));q("volume-level").textContent=vol;show(q("volume-overlay"),true);clearTimeout(volT);volT=setTimeout(function(){show(q("volume-overlay"),false)},1500)}
|
|
103
|
+
q("volume-up-button").addEventListener("click",function(){bump(10)});q("volume-down-button").addEventListener("click",function(){bump(-10)});
|
|
104
|
+
q("off-screen").addEventListener("click",powerOn);q("off-screen").addEventListener("keydown",function(e){if(e.key==="Enter"||e.key===" "){e.preventDefault();powerOn()}});
|
|
105
|
+
r.addEventListener("keydown",function(e){if(e.key==="Escape")closeMenu()});
|
|
106
|
+
document.addEventListener("pointerdown",function(e){if(shown("power-menu")&&!q("power-menu").contains(e.target)&&e.target!==pw)closeMenu()});
|
|
107
|
+
q("power-menu").addEventListener("click",function(e){var a=e.target.getAttribute&&e.target.getAttribute("data-act");if(!a)return;show(q("power-menu"),false);
|
|
108
|
+
if(a==="cancel"){pw.focus();return}
|
|
109
|
+
if(a==="restart"){show(q("boot-screen"),true);var f=document.getElementById(${JSON.stringify(iframeId)});if(f&&f.contentWindow)f.contentWindow.location.reload();clearTimeout(bootT);bootT=setTimeout(function(){show(q("boot-screen"),false)},1200)}
|
|
110
|
+
else{show(q("lock-screen"),false);show(q("off-screen"),true);pw.focus()}})})();
|
|
111
|
+
(function tick(){draw();var n=new Date();setTimeout(tick,(60-n.getSeconds())*1000-n.getMilliseconds()+50)})();
|
|
112
|
+
// The app can change its top colour (a theme switch) without any event we can hear, so look again every 2 seconds. Skipped while the page is hidden.
|
|
113
|
+
setInterval(function(){if(document.hidden)return;var f=document.getElementById(${JSON.stringify(iframeId)});if(f&&f.contentWindow)theme(f.contentWindow)},2000);
|
|
114
|
+
})();`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const svg = (w, h, body, extra = "") => `<svg class="sf-ico" ${extra} width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" aria-hidden="true">${body}</svg>`;
|
|
118
|
+
|
|
119
|
+
function icons(st) {
|
|
120
|
+
if (st.airplane) {
|
|
121
|
+
return svg(16, 16, `<path d="M8 1.5 9.4 6.5 15 9.5v1.4l-5.6-1.5-.4 3 1.6 1.2v1L8 14.7 5.4 15.6v-1L7 13.4l-.4-3L1 10.9V9.5l5.6-3z" fill="currentColor"/>`, 'data-part="airplane"');
|
|
122
|
+
}
|
|
123
|
+
let bars = "";
|
|
124
|
+
for (let i = 0; i < 4; i++) bars += `<rect x="${i * 4}" y="${10 - i * 3}" width="3" height="${3 + i * 3}" rx="1" fill="currentColor"${i < st.signal ? "" : ' opacity=".3"'}/>`;
|
|
125
|
+
const signal = svg(15, 12, bars, `data-part="signal" data-level="${st.signal}"`);
|
|
126
|
+
const wifi = st.wifi
|
|
127
|
+
? svg(16, 12, `<path d="M1 4.5a10 10 0 0 1 14 0M3.5 7.2a6.4 6.4 0 0 1 9 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="8" cy="10" r="1.4" fill="currentColor"/>`, 'data-part="wifi"')
|
|
128
|
+
: "";
|
|
129
|
+
return signal + wifi + battery(st.battery);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function battery(b) {
|
|
133
|
+
const w = Math.round((b.level / 100) * 15);
|
|
134
|
+
const bolt = b.charging ? `<path d="M12 2 7.5 7.5h3L9 11l4.5-5.5h-3z" fill="#000" opacity=".75"/>` : "";
|
|
135
|
+
return svg(25, 12, `<rect x=".75" y=".75" width="21.5" height="10.5" rx="3" fill="none" stroke="currentColor" stroke-width="1.5" opacity=".6"/><rect x="3.5" y="3.5" width="${w}" height="5" rx="1.5" fill="currentColor"/><rect x="23" y="4" width="2" height="4" rx="1" fill="currentColor" opacity=".6"/>${bolt}`, `data-part="battery" data-level="${b.level}"${b.charging ? ' data-charging="true"' : ""}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Renders the phone body.
|
|
140
|
+
* - skin: "android" (412x915, punch-hole camera) or "ios" (393x852, island cutout).
|
|
141
|
+
* - dark: true gives light status text on a dark bar, false gives dark text on a light bar.
|
|
142
|
+
* - status: { time, signal 0-4, wifi, airplane, battery (0-100, or { level, charging }) }.
|
|
143
|
+
* - screen: optional HTML placed in the screen area (the page puts its iframe there).
|
|
144
|
+
* Returns { skin, width, height, outerWidth, outerHeight, html, css }. width and height are the screen size.
|
|
145
|
+
*/
|
|
146
|
+
export function renderFrame({ skin = "android", dark = false, status, screen = "" } = {}) {
|
|
147
|
+
const k = SKINS[skin];
|
|
148
|
+
if (!k) throw new Error(`Unknown skin "${skin}". Use "android" or "ios".`);
|
|
149
|
+
const st = normalizeStatus(status);
|
|
150
|
+
const outerWidth = k.width + k.bezel * 2, outerHeight = k.height + k.bezel + k.bezel + FOOT;
|
|
151
|
+
const shape = skin === "ios"
|
|
152
|
+
? `<span class="sf-cut sf-island" data-part="dynamic-island"></span>`
|
|
153
|
+
: `<span class="sf-cut sf-hole" data-part="punch-hole"></span>`;
|
|
154
|
+
const html = `<div class="sf-body" data-skin="${skin}" data-theme="${dark ? "dark" : "light"}" data-part="phone-body">`
|
|
155
|
+
+ `<div class="sf-screen"><div class="sf-status" data-part="status-bar"><span class="sf-time" data-part="time">${st.time}</span>${shape}<span class="sf-icons">${icons(st)}</span></div>`
|
|
156
|
+
+ `<div class="sf-content">${screen}</div>`
|
|
157
|
+
+ lockScreenHtml(st.time) + bootScreenHtml() + offScreenHtml()
|
|
158
|
+
+ `<div class="sf-vol" data-part="volume-overlay" role="status" aria-live="polite" hidden><span data-part="volume-level">50</span></div><div class="sf-menu" data-part="power-menu" role="menu" aria-label="Power menu" hidden><button role="menuitem" data-act="restart">Restart</button><button role="menuitem" data-act="poweroff">Power off</button><button role="menuitem" data-act="cancel">Cancel</button></div>`
|
|
159
|
+
+ `<div class="sf-bar" data-part="${k.bottom}"><i></i></div></div>`
|
|
160
|
+
+ `<button class="sf-btn sf-power" data-part="power-button" aria-label="Power"></button><button class="sf-btn sf-volup" data-part="volume-up-button" aria-label="Volume up"></button><button class="sf-btn sf-voldown" data-part="volume-down-button" aria-label="Volume down"></button>`
|
|
161
|
+
+ `<span class="sf-label" data-part="simulated-label">Simulated</span></div>`;
|
|
162
|
+
const css = [
|
|
163
|
+
`.sf-body{--sf-bg:${dark ? "#000" : "#fff"};--sf-fg:${dark ? "#fff" : "#111"};position:relative;box-sizing:border-box;width:${outerWidth}px;height:${outerHeight}px;padding:${k.bezel}px ${k.bezel}px ${k.bezel + FOOT}px;`
|
|
164
|
+
+ `border-radius:${k.radius + k.bezel}px;background:linear-gradient(145deg,#3a3d42,#15171a);box-shadow:0 0 0 1px #000,0 10px 34px #0007;touch-action:manipulation;user-select:none;-webkit-user-select:none}`,
|
|
165
|
+
`.sf-screen{width:${k.width}px;height:${k.height}px;border-radius:${k.radius}px;background:var(--sf-bg);color:var(--sf-fg);overflow:hidden;display:flex;flex-direction:column;position:relative}`,
|
|
166
|
+
`.sf-status{flex:none;height:${k.status}px;display:flex;justify-content:space-between;align-items:center;padding:0 ${k.pad}px;font:600 14px system-ui,sans-serif;position:relative;z-index:2;color:var(--sf-status-fg,var(--sf-fg));background:var(--sf-status-bg,transparent)}`,
|
|
167
|
+
`.sf-icons{display:flex;align-items:center;gap:5px}.sf-ico{display:block}`,
|
|
168
|
+
`.sf-cut{position:absolute;left:50%;background:#000}`,
|
|
169
|
+
`.sf-hole{top:10px;width:14px;height:14px;margin-left:-7px;border-radius:50%;box-shadow:0 0 0 2px #1c1e21}`,
|
|
170
|
+
`.sf-island{top:11px;width:122px;height:34px;margin-left:-61px;border-radius:20px}`,
|
|
171
|
+
`.sf-content{flex:1;min-height:0;position:relative;background:#fff}`,
|
|
172
|
+
`.sf-content iframe{border:0;background:#fff;width:100%;height:100%;display:block}`,
|
|
173
|
+
`.sf-bar{flex:none;height:${k.bar}px;display:flex;justify-content:center;align-items:center;background:var(--sf-bg)}`,
|
|
174
|
+
`.sf-bar i{display:block;width:${k.indicator}px;height:5px;border-radius:3px;background:var(--sf-fg);opacity:.85}`,
|
|
175
|
+
`.sf-btn{position:absolute;right:-5px;width:5px;padding:0;border:0;border-radius:0 2px 2px 0;background:#5b6068;box-shadow:0 0 0 1px #000;cursor:pointer}.sf-btn::before{content:"";position:absolute;top:-6px;bottom:-6px;left:-4px;right:-10px}.sf-btn:focus-visible,.sf-lock button:focus-visible,.sf-menu button:focus-visible{outline:3px solid #4da3ff;outline-offset:2px}.sf-power{top:200px;height:80px}.sf-volup{top:310px;height:56px}.sf-voldown{top:376px;height:56px}`,
|
|
176
|
+
`.sf-lock,.sf-boot,.sf-off,.sf-menu,.sf-vol{position:absolute;z-index:5}.sf-lock[hidden],.sf-boot[hidden],.sf-off[hidden],.sf-menu[hidden],.sf-vol[hidden]{display:none}.sf-off{inset:0;z-index:7;background:#000;cursor:pointer}`,
|
|
177
|
+
`.sf-lock{inset:0;background:#10151c;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;font-family:system-ui,sans-serif}.sf-lock-time{font-size:64px;font-weight:300}.sf-lock-hint{font-size:14px;opacity:.7}.sf-lock button{margin-top:8px;padding:10px 28px;border:1px solid #fff6;border-radius:999px;background:transparent;color:#fff;font:600 15px system-ui,sans-serif;cursor:pointer}`,
|
|
178
|
+
`.sf-boot{inset:0;z-index:6;background:#000;color:#fff;display:flex;align-items:center;justify-content:center;font:700 34px system-ui,sans-serif;letter-spacing:.04em}`,
|
|
179
|
+
`.sf-vol{top:80px;right:12px;padding:8px 14px;border-radius:14px;background:#222d;color:#fff;font:600 14px system-ui,sans-serif}`,
|
|
180
|
+
`.sf-menu{left:50%;top:50%;transform:translate(-50%,-50%);display:flex;flex-direction:column;gap:8px;padding:14px;border-radius:16px;background:#222;box-shadow:0 8px 30px #0008}.sf-menu button{padding:10px 24px;border:0;border-radius:10px;background:#3a3d42;color:#fff;font:600 15px system-ui,sans-serif}`,
|
|
181
|
+
`.sf-label{position:absolute;left:0;right:0;bottom:3px;text-align:center;font:600 9px system-ui,sans-serif;letter-spacing:.08em;text-transform:uppercase;color:#9aa0a6}`,
|
|
182
|
+
].join("\n");
|
|
183
|
+
return { skin, width: k.width, height: k.height, outerWidth, outerHeight, html, css };
|
|
184
|
+
}
|
package/lifecycle.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Lock, unlock and restart for the simulated phone. Pure state: no DOM, no timers.
|
|
2
|
+
// The server keeps one of these per session and logs each step; the frame page draws the lock screen and boot screen.
|
|
3
|
+
|
|
4
|
+
/** Typed error for a lifecycle action that cannot run now. */
|
|
5
|
+
export class LifecycleError extends Error { constructor(code, message) { super(message); this.code = code; } }
|
|
6
|
+
|
|
7
|
+
/** A new phone state: unlocked, never restarted. `reloads` counts cold starts of the app (the first load is 1). */
|
|
8
|
+
export function createLifecycle() {
|
|
9
|
+
return { locked: false, reloads: 1, restarts: 0, log: [] };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const note = (st, name, extra = {}) => { st.log.push({ at: Date.now(), name, source: "simulated", ...extra }); };
|
|
13
|
+
|
|
14
|
+
/** Power press. Locks an unlocked phone. Throws already_locked if it is locked. */
|
|
15
|
+
export function lock(st) {
|
|
16
|
+
if (st.locked) throw new LifecycleError("already_locked", "the phone is already locked: call unlock first");
|
|
17
|
+
st.locked = true; note(st, "lock"); return st;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Swipe up on the lock screen. Throws not_locked if the phone is unlocked. The app and its screen are untouched. */
|
|
21
|
+
export function unlock(st) {
|
|
22
|
+
if (!st.locked) throw new LifecycleError("not_locked", "the phone is not locked");
|
|
23
|
+
st.locked = false; note(st, "unlock"); return st;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Restart from the power menu: the app cold-starts (the caller reloads it), storage is kept, the phone ends unlocked. */
|
|
27
|
+
export function restart(st) {
|
|
28
|
+
st.locked = false; st.restarts++; st.reloads++; note(st, "restart", { reloads: st.reloads }); return st;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Throws a typed error when the phone is locked, so no input reaches the app behind the lock screen. */
|
|
32
|
+
export function assertUnlocked(st) {
|
|
33
|
+
if (st.locked) throw new LifecycleError("phone_locked", "the phone is locked, so nothing was sent to the app. Call unlock first.");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Lock screen markup (time and a swipe hint). `time` is plain text such as "9:41". */
|
|
37
|
+
export function lockScreenHtml(time = "9:41") {
|
|
38
|
+
const t = String(time).replace(/[<>&"'\\]/g, "").slice(0, 12);
|
|
39
|
+
return `<div class="sf-lock" data-part="lock-screen" role="dialog" aria-label="Lock screen" tabindex="-1" hidden><div class="sf-lock-time" data-part="lock-time">${t}</div><div class="sf-lock-hint">Swipe up to unlock</div><button type="button" data-part="unlock-button">Unlock</button></div>`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Boot screen markup: the Shipvo name on black. Shown for a moment during restart. */
|
|
43
|
+
export function bootScreenHtml() {
|
|
44
|
+
return `<div class="sf-boot" data-part="boot-screen" hidden><div class="sf-boot-logo">Shipvo</div></div>`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Screen shown after "Power off": black. A click on it, or the power button, turns the phone on again. */
|
|
48
|
+
export function offScreenHtml() {
|
|
49
|
+
return `<div class="sf-off" data-part="off-screen" role="button" tabindex="0" aria-label="Phone is off. Press to power on" hidden></div>`;
|
|
50
|
+
}
|
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.5",
|
|
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`.",
|
|
@@ -9,11 +9,24 @@
|
|
|
9
9
|
},
|
|
10
10
|
"exports": {
|
|
11
11
|
"./server.mjs": "./server.mjs",
|
|
12
|
+
"./screen-rules.mjs": "./screen-rules.mjs",
|
|
13
|
+
"./frame.mjs": {
|
|
14
|
+
"types": "./frame.d.mts",
|
|
15
|
+
"default": "./frame.mjs"
|
|
16
|
+
},
|
|
12
17
|
"./package.json": "./package.json"
|
|
13
18
|
},
|
|
14
19
|
"files": [
|
|
15
20
|
"server.mjs",
|
|
21
|
+
"chromium-hint.mjs",
|
|
22
|
+
"browser-resolver.mjs",
|
|
16
23
|
"app-name.mjs",
|
|
24
|
+
"frame.mjs",
|
|
25
|
+
"lifecycle.mjs",
|
|
26
|
+
"frame.d.mts",
|
|
27
|
+
"mock-rules.mjs",
|
|
28
|
+
"screen-rules.mjs",
|
|
29
|
+
"push-notification.mjs",
|
|
17
30
|
"README.md"
|
|
18
31
|
],
|
|
19
32
|
"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/screen-rules.mjs
ADDED
|
@@ -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
|
@@ -9,13 +9,19 @@ 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";
|
|
16
|
+
import { resolveBrowser } from "./browser-resolver.mjs";
|
|
17
|
+
import { launchErrorMessage } from "./chromium-hint.mjs";
|
|
18
|
+
import { createLifecycle, lock, unlock, restart, assertUnlocked, LifecycleError } from "./lifecycle.mjs";
|
|
19
|
+
import { applyScreenRules, clearScreenRules, filterTree } from "./screen-rules.mjs";
|
|
14
20
|
// Playwright: the app's own install first (its browsers match it), then this package's, then a global one.
|
|
15
21
|
const require_ = createRequire(import.meta.url);
|
|
16
|
-
let pw;
|
|
17
|
-
for (const
|
|
18
|
-
try { pw =
|
|
22
|
+
let pw, pwVersion;
|
|
23
|
+
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)); }]) {
|
|
24
|
+
try { const r = from(); pw = r("playwright"); try { pwVersion = r("playwright/package.json").version; } catch {} break; } catch {}
|
|
19
25
|
}
|
|
20
26
|
if (!pw) { console.error("playwright is not installed: npm i -D playwright && npx playwright install chromium"); process.exit(2); }
|
|
21
27
|
const { chromium, devices } = pw;
|
|
@@ -40,7 +46,17 @@ async function resolveApp(spec) {
|
|
|
40
46
|
}
|
|
41
47
|
mkdirSync(DIR, { recursive: true });
|
|
42
48
|
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
|
-
|
|
49
|
+
// Chromium starts after the server is listening (see the end of this file). Handlers await this shared promise.
|
|
50
|
+
const LAUNCH_TIMEOUT_MS = 60_000;
|
|
51
|
+
let launchBrowser;
|
|
52
|
+
const browserReady = new Promise((resolve, reject) => { launchBrowser = () => {
|
|
53
|
+
// Resolve first (it may download a browser, which can take longer than the launch timeout), then time the launch only.
|
|
54
|
+
let opts;
|
|
55
|
+
try { opts = resolveBrowser({ pw, executablePath: arg("chromium", undefined) }); } catch (e) { reject(e); return; }
|
|
56
|
+
const timer = setTimeout(() => reject(new Error(`Chromium did not start within ${LAUNCH_TIMEOUT_MS / 1000} s`)), LAUNCH_TIMEOUT_MS);
|
|
57
|
+
chromium.launch(opts).then(resolve, reject).finally(() => clearTimeout(timer));
|
|
58
|
+
}; });
|
|
59
|
+
browserReady.catch((e) => { console.error(`sim: ${launchErrorMessage(e.message, pwVersion)}`); process.exit(1); });
|
|
44
60
|
const sessions = new Map();
|
|
45
61
|
|
|
46
62
|
const phone = { ...devices["Pixel 5"], viewport: { width: 393, height: 851 } };
|
|
@@ -49,12 +65,17 @@ async function newSession(id, opts = {}) {
|
|
|
49
65
|
if (sessions.has(id)) await sessions.get(id).ctx.close();
|
|
50
66
|
const target = await resolveApp(opts.app);
|
|
51
67
|
writeFileSync(join(DIR, `${id}.json`), JSON.stringify({ orders: [] })); writeFileSync(join(DIR, `${id}.json.log`), "");
|
|
52
|
-
const
|
|
68
|
+
const browser = await browserReady;
|
|
69
|
+
// Optional width, height (output pixels) and scale (device pixel ratio) make a session render at a store screenshot size.
|
|
70
|
+
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 } : {};
|
|
71
|
+
const ctx = await browser.newContext({ ...phone, ...size, colorScheme: opts.dark ? "dark" : "light" });
|
|
53
72
|
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
73
|
const page = await ctx.newPage();
|
|
74
|
+
const errors = [];
|
|
75
|
+
page.on("pageerror", (e) => errors.push(String(e?.message ?? e).split("\n")[0]));
|
|
55
76
|
await page.goto(target.url, { waitUntil: "networkidle" });
|
|
56
77
|
await page.waitForTimeout(300);
|
|
57
|
-
sessions.set(id, { ctx, page, app: target.app, release: target.release, startedAt: new Date().toISOString() });
|
|
78
|
+
sessions.set(id, { ctx, page, errors, app: target.app, release: target.release, startedAt: new Date().toISOString(), life: createLifecycle() });
|
|
58
79
|
}
|
|
59
80
|
|
|
60
81
|
// Refs are only meaningful for the document that issued them: a full page load restarts Playwright's counter, so an
|
|
@@ -71,50 +92,21 @@ class SimError extends Error { constructor(code, message) { super(message); this
|
|
|
71
92
|
async function screen(page) {
|
|
72
93
|
await page.waitForTimeout(150);
|
|
73
94
|
const epoch = await epochOf(page);
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
await page.evaluate(() => {
|
|
77
|
-
for (const el of document.querySelectorAll("[data-pn-offscreen]")) { el.removeAttribute("aria-hidden"); el.removeAttribute("data-pn-offscreen"); }
|
|
78
|
-
// A modal sheet (accessibilityViewIsModal / aria-modal) hides everything behind it from assistive tech on phones.
|
|
79
|
-
const modal = [...document.querySelectorAll('[aria-modal="true"]')].pop();
|
|
80
|
-
if (modal) for (let node = modal; node && node !== document.body; node = node.parentElement)
|
|
81
|
-
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"); }
|
|
82
|
-
for (const sc of document.querySelectorAll("div")) {
|
|
83
|
-
const cs = getComputedStyle(sc);
|
|
84
|
-
if (!/(auto|scroll)/.test(cs.overflowX) || sc.scrollWidth <= sc.clientWidth + 1) continue;
|
|
85
|
-
const box = sc.getBoundingClientRect();
|
|
86
|
-
const row = sc.firstElementChild; if (!row) continue;
|
|
87
|
-
for (const item of row.children) {
|
|
88
|
-
const r = item.getBoundingClientRect();
|
|
89
|
-
if (r.right <= box.left + 1 || r.left >= box.right - 1) { item.setAttribute("aria-hidden", "true"); item.setAttribute("data-pn-offscreen", "1"); }
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
});
|
|
95
|
+
// Hide what a phone would not expose, so an agent learns to swipe a rail exactly as it must on a device.
|
|
96
|
+
await page.evaluate(`(${clearScreenRules})(document); (${applyScreenRules})(document)`);
|
|
93
97
|
// What is hidden from assistive tech must not appear at all: this Playwright keeps aria-hidden nodes but strips
|
|
94
98
|
// their names. `inert` removes them from the accessibility tree; it is set only for the snapshot and removed after.
|
|
95
99
|
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"); } });
|
|
96
100
|
const tree = await page.locator("body").ariaSnapshot({ mode: "ai" });
|
|
97
101
|
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"); } });
|
|
98
|
-
const
|
|
99
|
-
// A screen reader announces a control's label, not the words inside it (Android and iOS group a pressable's
|
|
100
|
-
// children into one element). Drop the inner text lines of labelled controls, as the phones do.
|
|
101
|
-
const out = []; let hideBelow = -1;
|
|
102
|
-
for (const l of lines) {
|
|
103
|
-
const indent = l.search(/\S/);
|
|
104
|
-
if (hideBelow >= 0 && indent > hideBelow) continue;
|
|
105
|
-
hideBelow = -1;
|
|
106
|
-
if (/\[aria-hidden\]/.test(l)) { hideBelow = indent; continue; } // hidden from assistive tech: not on screen
|
|
107
|
-
out.push(l);
|
|
108
|
-
if (/^\s*- (button|radio|link|checkbox|switch|tab) "[^"]+"/.test(l) && /:\s*$/.test(l)) hideBelow = indent;
|
|
109
|
-
}
|
|
110
|
-
const text = out.join("\n");
|
|
102
|
+
const text = filterTree(tree);
|
|
111
103
|
return epoch ? text.replace(/\[ref=(e\d+)\]/g, `[ref=p${epoch}$1]`) : text;
|
|
112
104
|
}
|
|
113
105
|
|
|
114
106
|
function log(id, action, ok, extra = {}) { appendFileSync(join(DIR, `${id}.json.log`), JSON.stringify({ at: Date.now(), name: action, ok, ...extra }) + "\n"); }
|
|
115
107
|
|
|
116
108
|
async function act(id, body) {
|
|
117
|
-
const s = sessions.get(id); if (!s) throw new
|
|
109
|
+
const s = sessions.get(id); if (!s) throw new SimError("no_session", `no session '${id}': call /session first`);
|
|
118
110
|
const { page } = s;
|
|
119
111
|
// 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
112
|
// 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 +121,38 @@ async function act(id, body) {
|
|
|
129
121
|
if ((await l.count()) !== 1) throw stale();
|
|
130
122
|
return l;
|
|
131
123
|
};
|
|
124
|
+
// Before acting on an element, say why it cannot receive the action. Nothing has reached the app when this throws.
|
|
125
|
+
const NOT_DELIVERED = { not_visible: "is not visible", disabled: "is disabled", covered: "is covered by another element" };
|
|
126
|
+
const ready = async (l) => {
|
|
127
|
+
const fail = (reason, what) => new SimError(reason, `${what} ${NOT_DELIVERED[reason]}, so nothing was sent to the app.`);
|
|
128
|
+
if (!(await l.isVisible())) throw fail("not_visible", String(body.ref));
|
|
129
|
+
// Still off the screen after scrolling it into view (for example a fixed element parked outside the window).
|
|
130
|
+
await l.scrollIntoViewIfNeeded({ timeout: 1000 }).catch(() => {});
|
|
131
|
+
const box = await l.boundingBox(); const vp = page.viewportSize();
|
|
132
|
+
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));
|
|
133
|
+
if (await l.isDisabled()) throw fail("disabled", String(body.ref));
|
|
134
|
+
const covered = await l.evaluate((el) => {
|
|
135
|
+
const r = el.getBoundingClientRect();
|
|
136
|
+
const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
|
137
|
+
return !!top && top !== el && !el.contains(top) && !top.contains(el);
|
|
138
|
+
});
|
|
139
|
+
if (covered) throw fail("covered", String(body.ref));
|
|
140
|
+
};
|
|
141
|
+
// A locked phone takes no input: say so with a typed error before anything reaches the app.
|
|
142
|
+
if (["tap", "type", "scroll", "back", "open_url", "open_notification"].includes(body.action)) { try { assertUnlocked(s.life); } catch (e) { throw new SimError(e.code, e.message); } }
|
|
132
143
|
switch (body.action) {
|
|
144
|
+
// Simulated lock, unlock and restart. Restart reloads the page (a cold start of the app); the browser context keeps storage.
|
|
145
|
+
case "lock": case "unlock": {
|
|
146
|
+
try { (body.action === "lock" ? lock : unlock)(s.life); } catch (e) { if (e instanceof LifecycleError) throw new SimError(e.code, e.message); throw e; }
|
|
147
|
+
return { locked: s.life.locked, screen: s.life.locked ? "Lock screen" : await screen(page) };
|
|
148
|
+
}
|
|
149
|
+
case "restart": {
|
|
150
|
+
restart(s.life);
|
|
151
|
+
await page.reload({ waitUntil: "networkidle" }); await page.waitForTimeout(300);
|
|
152
|
+
return { reloads: s.life.reloads, restarts: s.life.restarts, screen: await screen(page) };
|
|
153
|
+
}
|
|
133
154
|
case "screen": break;
|
|
134
|
-
case "tap":
|
|
155
|
+
case "tap": { const t = await resolve(body.ref); await ready(t); await t.click({ timeout: 3000 }); break; }
|
|
135
156
|
case "type": {
|
|
136
157
|
// A labelled text field may resolve to its wrapper; type into the real input inside it, as a phone focuses the field.
|
|
137
158
|
let t = await resolve(body.ref);
|
|
@@ -143,7 +164,9 @@ async function act(id, body) {
|
|
|
143
164
|
if ((await named.count()) !== 1) throw new Error(`${body.ref} is not a text field. Type into a "textbox" ref.`);
|
|
144
165
|
t = named;
|
|
145
166
|
}
|
|
146
|
-
await
|
|
167
|
+
await ready(t);
|
|
168
|
+
await t.fill(String(body.text ?? ""), { timeout: 3000 }); if (body.enter) await t.press("Enter");
|
|
169
|
+
return { delivery: "delivered", value: await t.inputValue().catch(() => null), screen: await screen(page) };
|
|
147
170
|
}
|
|
148
171
|
case "scroll": {
|
|
149
172
|
const target = body.ref ? await resolve(body.ref) : page.locator("body");
|
|
@@ -154,10 +177,34 @@ async function act(id, body) {
|
|
|
154
177
|
}
|
|
155
178
|
// Simulated device: what the phone "has" and what happens to it. See @shipvo/device.
|
|
156
179
|
case "set_device": await page.evaluate((d) => globalThis.__pnDevice?.set(d), body.device ?? {}); break;
|
|
157
|
-
case "push_notification":
|
|
180
|
+
case "push_notification": return { ...(await pushNotification(page, body)), screen: await screen(page) };
|
|
158
181
|
case "notifications": return { notifications: await page.evaluate(() => globalThis.__pnDevice?.notifications() ?? []), screen: await screen(page) };
|
|
159
182
|
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; }
|
|
160
|
-
case "
|
|
183
|
+
case "open_url": {
|
|
184
|
+
if (typeof body.url !== "string" || !body.url) throw new SimError("bad_url", "open_url needs a url, such as myapp://item/3");
|
|
185
|
+
const handled = await page.evaluate((u) => globalThis.__pnDevice?.openUrl(u), body.url);
|
|
186
|
+
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.`);
|
|
187
|
+
await page.waitForTimeout(400); break;
|
|
188
|
+
}
|
|
189
|
+
// Stub fetch/XHR on the page. Rules live on the session; unmatched requests continue untouched.
|
|
190
|
+
case "set_network_mocks": {
|
|
191
|
+
let rules; try { rules = parseMockRules(body.rules ?? []); } catch (e) { throw new SimError(e.code ?? "mock_invalid", e.message); }
|
|
192
|
+
s.mocks = rules;
|
|
193
|
+
if (!s.mockRoute) {
|
|
194
|
+
s.mockRoute = true;
|
|
195
|
+
await page.route("**/*", async (route) => {
|
|
196
|
+
const req = route.request();
|
|
197
|
+
const type = req.resourceType();
|
|
198
|
+
const rule = (type === "fetch" || type === "xhr") && (s.mocks ?? []).find((m) => (!m.method || m.method === req.method()) && m.match(req.url()));
|
|
199
|
+
if (!rule) return route.fallback();
|
|
200
|
+
if (rule.delayMs) await new Promise((r) => setTimeout(r, rule.delayMs));
|
|
201
|
+
// The app page may come from another origin, so the stub must pass the browser's CORS check.
|
|
202
|
+
await route.fulfill({ status: rule.status, body: rule.body, headers: { "access-control-allow-origin": "*", ...rule.headers } }).catch(() => {});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
case "device_log": return { log: [...(await page.evaluate(() => globalThis.__pnDevice?.log() ?? [])), ...s.life.log], capabilities: await page.evaluate(() => globalThis.__pnDevice?.capabilities() ?? null), screen: await screen(page) };
|
|
161
208
|
case "diag": {
|
|
162
209
|
const r = await page.evaluate(async () => {
|
|
163
210
|
const cfg = globalThis.__PN_SIM__ ?? {}; const out = { baseUrl: cfg.baseUrl, origin: location.origin };
|
|
@@ -168,13 +215,22 @@ async function act(id, body) {
|
|
|
168
215
|
return { diag: r, screen: await screen(page) };
|
|
169
216
|
}
|
|
170
217
|
case "device_reset": await page.evaluate((d) => globalThis.__pnDevice?.reset(d), body.device ?? {}); break;
|
|
171
|
-
|
|
172
|
-
case "
|
|
173
|
-
|
|
218
|
+
// Uncaught page errors since the last call to this action. Reading them clears the list.
|
|
219
|
+
case "errors": return { errors: s.errors.splice(0), screen: await screen(page) };
|
|
220
|
+
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; }
|
|
221
|
+
case "screenshot": {
|
|
222
|
+
// format "jpeg" has no alpha channel, which the stores require. inline: true also returns the bytes as base64 for clients on another machine.
|
|
223
|
+
const jpeg = body.format === "jpeg"; const p = join(DIR, `${id}-${Date.now()}.${jpeg ? "jpg" : "png"}`);
|
|
224
|
+
const buf = await page.screenshot(jpeg ? { path: p, type: "jpeg", quality: 90 } : { path: p });
|
|
225
|
+
return { path: p, ...(body.inline ? { data: buf.toString("base64") } : {}), screen: await screen(page) };
|
|
226
|
+
}
|
|
227
|
+
default: throw new Error(`unknown action '${body.action}'. Actions: screen, lock, unlock, restart, tap, type, scroll, back, screenshot, set_device, push_notification, notifications, open_notification, open_url, errors, 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();
|